common.dart 13.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:async';

7
import 'package:args/command_runner.dart';
8
import 'package:collection/collection.dart';
9
import 'package:file/memory.dart';
10 11 12 13
import 'package:flutter_tools/src/base/common.dart';
import 'package:flutter_tools/src/base/context.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
14
import 'package:flutter_tools/src/base/platform.dart';
15
import 'package:flutter_tools/src/globals.dart' as globals;
16
import 'package:meta/meta.dart';
17
import 'package:path/path.dart' as path; // flutter_ignore: package_path_import
18 19
import 'package:test/test.dart' as test_package show test;
import 'package:test/test.dart' hide test;
20 21
// TODO(goderbauer): Fix this ignore when https://github.com/dart-lang/tools/issues/234 is resolved.
import 'package:unified_analytics/src/enums.dart' show DevicePlatform; // ignore: implementation_imports
22 23 24
import 'package:unified_analytics/unified_analytics.dart';

import 'fakes.dart';
25

26
export 'package:path/path.dart' show Context; // flutter_ignore: package_path_import
27
export 'package:test/test.dart' hide isInstanceOf, test;
28

29
void tryToDelete(FileSystemEntity fileEntity) {
30 31 32 33
  // This should not be necessary, but it turns out that
  // on Windows it's common for deletions to fail due to
  // bogus (we think) "access denied" errors.
  try {
34 35
    if (fileEntity.existsSync()) {
      fileEntity.deleteSync(recursive: true);
36
    }
37
  } on FileSystemException catch (error) {
38 39 40
    // We print this so that it's visible in the logs, to get an idea of how
    // common this problem is, and if any patterns are ever noticed by anyone.
    // ignore: avoid_print
41
    print('Failed to delete ${fileEntity.path}: $error');
42 43 44
  }
}

45 46 47 48 49 50
/// Gets the path to the root of the Flutter repository.
///
/// This will first look for a `FLUTTER_ROOT` environment variable. If the
/// environment variable is set, it will be returned. Otherwise, this will
/// deduce the path from `platform.script`.
String getFlutterRoot() {
51 52
  const Platform platform = LocalPlatform();
  if (platform.environment.containsKey('FLUTTER_ROOT')) {
53
    return platform.environment['FLUTTER_ROOT']!;
54
  }
55

56
  Error invalidScript() => StateError('Could not determine flutter_tools/ path from script URL (${globals.platform.script}); consider setting FLUTTER_ROOT explicitly.');
57 58

  Uri scriptUri;
59
  switch (platform.script.scheme) {
60
    case 'file':
61
      scriptUri = platform.script;
62
    case 'data':
63
      final RegExp flutterTools = RegExp(r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)', multiLine: true);
64
      final Match? match = flutterTools.firstMatch(Uri.decodeFull(platform.script.path));
65
      if (match == null) {
66
        throw invalidScript();
67
      }
68
      scriptUri = Uri.parse(match.group(1)!);
69 70 71 72
    default:
      throw invalidScript();
  }

73
  final List<String> parts = path.split(globals.localFileSystem.path.fromUri(scriptUri));
74
  final int toolsIndex = parts.indexOf('flutter_tools');
75
  if (toolsIndex == -1) {
76
    throw invalidScript();
77
  }
78 79
  final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1));
  return path.normalize(path.join(toolsPath, '..', '..'));
80 81
}

82 83 84 85 86 87 88 89 90 91 92 93
/// Capture console print events into a string buffer.
Future<StringBuffer> capturedConsolePrint(Future<void> Function() body) async {
  final StringBuffer buffer = StringBuffer();
  await runZoned<Future<void>>(() async {
    // Service the event loop.
    await body();
  }, zoneSpecification: ZoneSpecification(print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
    buffer.writeln(line);
  }));
  return buffer;
}

Dan Field's avatar
Dan Field committed
94 95 96
/// Matcher for functions that throw [AssertionError].
final Matcher throwsAssertionError = throwsA(isA<AssertionError>());

97
/// Matcher for functions that throw [ToolExit].
98 99
///
/// [message] is matched using the [contains] matcher.
100
Matcher throwsToolExit({ int? exitCode, Pattern? message }) {
101 102
  TypeMatcher<ToolExit> result = const TypeMatcher<ToolExit>();

103
  if (exitCode != null) {
104
    result = result.having((ToolExit e) => e.exitCode, 'exitCode', equals(exitCode));
105 106
  }
  if (message != null) {
107
    result = result.having((ToolExit e) => e.message, 'message', contains(message));
108
  }
109

110 111
  return throwsA(result);
}
112

113 114 115 116 117 118 119 120 121 122 123 124
/// Matcher for functions that throw [UsageException].
Matcher throwsUsageException({Pattern? message }) {
  Matcher matcher = _isUsageException;
  if (message != null) {
    matcher = allOf(matcher, (UsageException e) => e.message.contains(message));
  }
  return throwsA(matcher);
}

/// Matcher for [UsageException]s.
final TypeMatcher<UsageException> _isUsageException = isA<UsageException>();

125
/// Matcher for functions that throw [ProcessException].
126 127
Matcher throwsProcessException({ Pattern? message }) {
  Matcher matcher = _isProcessException;
128
  if (message != null) {
129
    matcher = allOf(matcher, (ProcessException e) => e.message.contains(message));
130 131
  }
  return throwsA(matcher);
132 133
}

134
/// Matcher for [ProcessException]s.
135
final TypeMatcher<ProcessException> _isProcessException = isA<ProcessException>();
136

137 138 139 140
Future<void> expectToolExitLater(Future<dynamic> future, Matcher messageMatcher) async {
  try {
    await future;
    fail('ToolExit expected, but nothing thrown');
141
  } on ToolExit catch (e) {
142
    expect(e.message, messageMatcher);
143 144
  // Catch all exceptions to give a better test failure message.
  } catch (e, trace) { // ignore: avoid_catches_without_on_clauses
145 146 147
    fail('ToolExit expected, got $e\n$trace');
  }
}
148

149 150 151 152 153 154 155 156 157
Future<void> expectReturnsNormallyLater(Future<dynamic> future) async {
  try {
    await future;
  // Catch all exceptions to give a better test failure message.
  } catch (e, trace) { // ignore: avoid_catches_without_on_clauses
    fail('Expected to run with no exceptions, got $e\n$trace');
  }
}

kwkr's avatar
kwkr committed
158 159 160 161 162 163 164 165 166
Matcher containsIgnoringWhitespace(String toSearch) {
  return predicate(
    (String source) {
      return collapseWhitespace(source).contains(collapseWhitespace(toSearch));
    },
    'contains "$toSearch" ignoring whitespace.',
  );
}

167 168 169 170
/// The tool overrides `test` to ensure that files created under the
/// system temporary directory are deleted after each test by calling
/// `LocalFileSystem.dispose()`.
@isTest
171
void test(String description, FutureOr<void> Function() body, {
172
  String? testOn,
173
  dynamic skip,
174 175 176
  List<String>? tags,
  Map<String, dynamic>? onPlatform,
  int? retry,
177 178 179 180 181
}) {
  test_package.test(
    description,
    () async {
      addTearDown(() async {
182
        await globals.localFileSystem.dispose();
183
      });
184

185 186 187 188 189 190 191
      return body();
    },
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
192 193 194
    // We don't support "timeout"; see ../../dart_test.yaml which
    // configures all tests to have a 15 minute timeout which should
    // definitely be enough.
195 196 197
  );
}

198 199
/// Executes a test body in zone that does not allow context-based injection.
///
200
/// For classes which have been refactored to exclude context-based injection
201 202 203 204 205 206
/// or globals like [fs] or [platform], prefer using this test method as it
/// will prevent accidentally including these context getters in future code
/// changes.
///
/// For more information, see https://github.com/flutter/flutter/issues/47161
@isTest
207
void testWithoutContext(String description, FutureOr<void> Function() body, {
208
  String? testOn,
209
  dynamic skip,
210 211 212
  List<String>? tags,
  Map<String, dynamic>? onPlatform,
  int? retry,
213
}) {
214
  return test(
215 216
    description, () async {
      return runZoned(body, zoneValues: <Object, Object>{
217
        contextKey: const _NoContext(),
218 219 220 221 222 223 224
      });
    },
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
225 226 227
    // We don't support "timeout"; see ../../dart_test.yaml which
    // configures all tests to have a 15 minute timeout which should
    // definitely be enough.
228 229 230 231 232 233 234 235
  );
}

/// An implementation of [AppContext] that throws if context.get is called in the test.
///
/// The intention of the class is to ensure we do not accidentally regress when
/// moving towards more explicit dependency injection by accidentally using
/// a Zone value in place of a constructor parameter.
236 237
class _NoContext implements AppContext {
  const _NoContext();
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252

  @override
  T get<T>() {
    throw UnsupportedError(
      'context.get<$T> is not supported in test methods. '
      'Use Testbed or testUsingContext if accessing Zone injected '
      'values.'
    );
  }

  @override
  String get name => 'No Context';

  @override
  Future<V> run<V>({
253 254 255 256 257
    required FutureOr<V> Function() body,
    String? name,
    Map<Type, Generator>? overrides,
    Map<Type, Generator>? fallbacks,
    ZoneSpecification? zoneSpecification,
258 259 260 261
  }) async {
    return body();
  }
}
262

263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
/// Allows inserting file system exceptions into certain
/// [MemoryFileSystem] operations by tagging path/op combinations.
///
/// Example use:
///
/// ```
/// void main() {
///   var handler = FileExceptionHandler();
///   var fs = MemoryFileSystem(opHandle: handler.opHandle);
///
///   var file = fs.file('foo')..createSync();
///   handler.addError(file, FileSystemOp.read, FileSystemException('Error Reading foo'));
///
///   expect(() => file.writeAsStringSync('A'), throwsA(isA<FileSystemException>()));
/// }
/// ```
class FileExceptionHandler {
  final Map<String, Map<FileSystemOp, FileSystemException>> _contextErrors = <String, Map<FileSystemOp, FileSystemException>>{};
281 282
  final Map<FileSystemOp, FileSystemException> _tempErrors = <FileSystemOp, FileSystemException>{};
  static final RegExp _tempDirectoryEnd = RegExp('rand[0-9]+');
283 284 285 286 287 288

  /// Add an exception that will be thrown whenever the file system attached to this
  /// handler performs the [operation] on the [entity].
  void addError(FileSystemEntity entity, FileSystemOp operation, FileSystemException exception) {
    final String path = entity.path;
    _contextErrors[path] ??= <FileSystemOp, FileSystemException>{};
289
    _contextErrors[path]![operation] = exception;
290 291
  }

292 293 294 295 296
  void addTempError(FileSystemOp operation, FileSystemException exception) {
    _tempErrors[operation] = exception;
  }

  /// Tear-off this method and pass it to the memory filesystem `opHandle` parameter.
297
  void opHandle(String path, FileSystemOp operation) {
298 299 300 301 302 303
    if (path.startsWith('.tmp_') || _tempDirectoryEnd.firstMatch(path) != null) {
      final FileSystemException? exception = _tempErrors[operation];
      if (exception != null) {
        throw exception;
      }
    }
304
    final Map<FileSystemOp, FileSystemException>? exceptions = _contextErrors[path];
305 306 307
    if (exceptions == null) {
      return;
    }
308
    final FileSystemException? exception = exceptions[operation];
309 310 311 312 313 314
    if (exception == null) {
      return;
    }
    throw exception;
  }
}
315 316 317 318 319 320 321 322 323 324

/// This method is required to fetch an instance of [FakeAnalytics]
/// because there is initialization logic that is required. An initial
/// instance will first be created and will let package:unified_analytics
/// know that the consent message has been shown. After confirming on the first
/// instance, then a second instance will be generated and returned. This second
/// instance will be cleared to send events.
FakeAnalytics getInitializedFakeAnalyticsInstance({
  required FileSystem fs,
  required FakeFlutterVersion fakeFlutterVersion,
325
  String? clientIde,
326
  String? enabledFeatures,
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349
}) {
  final Directory homeDirectory = fs.directory('/');
  final FakeAnalytics initialAnalytics = FakeAnalytics(
    tool: DashTool.flutterTool,
    homeDirectory: homeDirectory,
    dartVersion: fakeFlutterVersion.dartSdkVersion,
    platform: DevicePlatform.linux,
    fs: fs,
    surveyHandler: SurveyHandler(homeDirectory: homeDirectory, fs: fs),
    flutterChannel: fakeFlutterVersion.channel,
    flutterVersion: fakeFlutterVersion.getVersionString(),
  );
  initialAnalytics.clientShowedMessage();

  return FakeAnalytics(
    tool: DashTool.flutterTool,
    homeDirectory: homeDirectory,
    dartVersion: fakeFlutterVersion.dartSdkVersion,
    platform: DevicePlatform.linux,
    fs: fs,
    surveyHandler: SurveyHandler(homeDirectory: homeDirectory, fs: fs),
    flutterChannel: fakeFlutterVersion.channel,
    flutterVersion: fakeFlutterVersion.getVersionString(),
350
    clientIde: clientIde,
351
    enabledFeatures: enabledFeatures,
352 353
  );
}
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378

/// Returns "true" if the timing event searched for exists in [sentEvents].
///
/// This utility function allows us to check for an instance of
/// [Event.timing] within a [FakeAnalytics] instance. Normally, we can
/// use the equality operator for [Event] to check if the event exists, but
/// we are unable to do so for the timing event because the elapsed time
/// is variable so we cannot predict what that value will be in tests.
///
/// This function allows us to check for the other keys that have
/// string values by removing the `elapsedMilliseconds` from the
/// [Event.eventData] map and checking for a match.
bool analyticsTimingEventExists({
  required List<Event> sentEvents,
  required String workflow,
  required String variableName,
  String? label,
}) {
  final Map<String, String> lookup = <String, String>{
    'workflow': workflow,
    'variableName': variableName,
    if (label != null) 'label': label,
  };

  for (final Event e in sentEvents) {
379
    final Map<String, Object?> eventData = <String, Object?>{...e.eventData};
380 381 382 383 384 385 386 387 388
    eventData.remove('elapsedMilliseconds');

    if (const DeepCollectionEquality().equals(lookup, eventData)) {
      return true;
    }
  }

  return false;
}