| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'dart:io' as io show IOOverrides; | |
| import 'package:args/command_runner.dart'; | |
| import 'package:collection/collection.dart'; | |
| import 'package:file/memory.dart'; | |
| 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'; | |
| import 'package:flutter_tools/src/base/platform.dart'; | |
| import 'package:flutter_tools/src/globals.dart' as globals; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; // flutter_ignore: package_path_import | |
| import 'package:test/test.dart' as test_package show test; | |
| import 'package:test/test.dart' hide test; | |
| import 'package:unified_analytics/unified_analytics.dart'; | |
| import 'fakes.dart'; | |
| import 'fs_safety.dart'; | |
| export 'package:path/path.dart' show Context; // flutter_ignore: package_path_import | |
| export 'package:test/test.dart' hide isInstanceOf, test; | |
| void tryToDelete(FileSystemEntity fileEntity) { | |
| // 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 { | |
| if (fileEntity.existsSync()) { | |
| fileEntity.deleteSync(recursive: true); | |
| } | |
| } on FileSystemException catch (error) { | |
| // 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 | |
| print('Failed to delete ${fileEntity.path}: $error'); | |
| } | |
| } | |
| /// 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() { | |
| const Platform platform = LocalPlatform(); | |
| if (platform.environment.containsKey('FLUTTER_ROOT')) { | |
| return platform.environment['FLUTTER_ROOT']!; | |
| } | |
| Error invalidScript() => StateError( | |
| 'Could not determine flutter_tools/ path from script URL (${platform.script}); consider setting FLUTTER_ROOT explicitly.', | |
| ); | |
| Uri scriptUri; | |
| switch (platform.script.scheme) { | |
| case 'file': | |
| scriptUri = platform.script; | |
| case 'data': | |
| final flutterTools = RegExp( | |
| r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)', | |
| multiLine: true, | |
| ); | |
| final Match? match = flutterTools.firstMatch(Uri.decodeFull(platform.script.path)); | |
| if (match == null) { | |
| throw invalidScript(); | |
| } | |
| scriptUri = Uri.parse(match.group(1)!); | |
| default: | |
| throw invalidScript(); | |
| } | |
| final List<String> parts = path.split(globals.localFileSystem.path.fromUri(scriptUri)); | |
| final int toolsIndex = parts.indexOf('flutter_tools'); | |
| if (toolsIndex == -1) { | |
| throw invalidScript(); | |
| } | |
| final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1)); | |
| return path.normalize(path.join(toolsPath, '..', '..')); | |
| } | |
| /// Capture console print events into a string buffer. | |
| Future<StringBuffer> capturedConsolePrint(Future<void> Function() body) async { | |
| final 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; | |
| } | |
| /// Matcher for functions that throw [AssertionError]. | |
| final Matcher throwsAssertionError = throwsA(isA<AssertionError>()); | |
| /// Matcher for functions that throw [ToolExit]. | |
| /// | |
| /// [message] is matched using the [contains] matcher. | |
| Matcher throwsToolExit({int? exitCode, Pattern? message}) { | |
| var result = const TypeMatcher<ToolExit>(); | |
| if (exitCode != null) { | |
| result = result.having((ToolExit e) => e.exitCode, 'exitCode', equals(exitCode)); | |
| } | |
| if (message != null) { | |
| result = result.having((ToolExit e) => e.message, 'message', contains(message)); | |
| } | |
| return throwsA(result); | |
| } | |
| /// 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>(); | |
| /// Matcher for functions that throw [ProcessException]. | |
| Matcher throwsProcessException({Pattern? message}) { | |
| Matcher matcher = _isProcessException; | |
| if (message != null) { | |
| matcher = allOf(matcher, (ProcessException e) => e.message.contains(message)); | |
| } | |
| return throwsA(matcher); | |
| } | |
| /// Matcher for [ProcessException]s. | |
| final TypeMatcher<ProcessException> _isProcessException = isA<ProcessException>(); | |
| Future<void> expectToolExitLater(Future<dynamic> future, Matcher messageMatcher) async { | |
| try { | |
| await future; | |
| fail('ToolExit expected, but nothing thrown'); | |
| } on ToolExit catch (e) { | |
| expect(e.message, messageMatcher); | |
| // Catch all exceptions to give a better test failure message. | |
| } catch (e, trace) { | |
| fail('ToolExit expected, got $e\n$trace'); | |
| } | |
| } | |
| Future<void> expectReturnsNormallyLater(Future<dynamic> future) async { | |
| try { | |
| await future; | |
| // Catch all exceptions to give a better test failure message. | |
| } catch (e, trace) { | |
| fail('Expected to run with no exceptions, got $e\n$trace'); | |
| } | |
| } | |
| Matcher containsIgnoringWhitespace(String toSearch) { | |
| return predicate((String source) { | |
| return collapseWhitespace(source).contains(collapseWhitespace(toSearch)); | |
| }, 'contains "$toSearch" ignoring whitespace.'); | |
| } | |
| /// The tool overrides `test` to ensure that files created under the | |
| /// system temporary directory are deleted after each test by calling | |
| /// `LocalFileSystem.dispose()`. | |
| @isTest | |
| void test( | |
| String description, | |
| FutureOr<void> Function() body, { | |
| String? testOn, | |
| dynamic skip, | |
| List<String>? tags, | |
| Map<String, dynamic>? onPlatform, | |
| int? retry, | |
| }) { | |
| test_package.test( | |
| description, | |
| () async { | |
| addTearDown(() async { | |
| await globals.localFileSystem.dispose(); | |
| }); | |
| return io.IOOverrides.runWithIOOverrides(() => body(), FSGuardIOOverrides()); | |
| }, | |
| skip: skip, | |
| tags: tags, | |
| onPlatform: onPlatform, | |
| retry: retry, | |
| testOn: testOn, | |
| // We don't support "timeout"; see ../../dart_test.yaml which | |
| // configures all tests to have a 15 minute timeout which should | |
| // definitely be enough. | |
| ); | |
| } | |
| /// Executes a test body in zone that does not allow context-based injection. | |
| /// | |
| /// For classes which have been refactored to exclude context-based injection | |
| /// or globals like [globals.fs] or [globals.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 | |
| void testWithoutContext( | |
| String description, | |
| FutureOr<void> Function() body, { | |
| String? testOn, | |
| dynamic skip, | |
| List<String>? tags, | |
| Map<String, dynamic>? onPlatform, | |
| int? retry, | |
| }) { | |
| return test( | |
| description, | |
| () async { | |
| return runZoned(body, zoneValues: <Object, Object>{contextKey: const _NoContext()}); | |
| }, | |
| skip: skip, | |
| tags: tags, | |
| onPlatform: onPlatform, | |
| retry: retry, | |
| testOn: testOn, | |
| // We don't support "timeout"; see ../../dart_test.yaml which | |
| // configures all tests to have a 15 minute timeout which should | |
| // definitely be enough. | |
| ); | |
| } | |
| /// 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. | |
| class _NoContext implements AppContext { | |
| const _NoContext(); | |
| @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>({ | |
| required FutureOr<V> Function() body, | |
| String? name, | |
| Map<Type, Generator>? overrides, | |
| Map<Type, Generator>? fallbacks, | |
| ZoneSpecification? zoneSpecification, | |
| }) async { | |
| return body(); | |
| } | |
| } | |
| /// Allows inserting file system exceptions into certain | |
| /// [MemoryFileSystem] operations by tagging path/op combinations. | |
| /// | |
| /// Example use: | |
| /// | |
| /// ```dart | |
| /// 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 _contextErrors = <String, Map<FileSystemOp, FileSystemException>>{}; | |
| final _tempErrors = <FileSystemOp, FileSystemException>{}; | |
| static final _tempDirectoryEnd = RegExp('rand[0-9]+'); | |
| /// 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>{}; | |
| _contextErrors[path]![operation] = exception; | |
| } | |
| void addTempError(FileSystemOp operation, FileSystemException exception) { | |
| _tempErrors[operation] = exception; | |
| } | |
| /// Tear-off this method and pass it to the memory filesystem `opHandle` parameter. | |
| void opHandle(String path, FileSystemOp operation) { | |
| if (path.startsWith('.tmp_') || _tempDirectoryEnd.firstMatch(path) != null) { | |
| final FileSystemException? exception = _tempErrors[operation]; | |
| if (exception != null) { | |
| throw exception; | |
| } | |
| } | |
| final Map<FileSystemOp, FileSystemException>? exceptions = _contextErrors[path]; | |
| if (exceptions == null) { | |
| return; | |
| } | |
| final FileSystemException? exception = exceptions[operation]; | |
| if (exception == null) { | |
| return; | |
| } | |
| throw exception; | |
| } | |
| } | |
| /// 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 MemoryFileSystem fs, | |
| required FakeFlutterVersion fakeFlutterVersion, | |
| String? clientIde, | |
| String? enabledFeatures, | |
| }) { | |
| final Directory homeDirectory = fs.directory('/'); | |
| final FakeAnalytics initialAnalytics = Analytics.fake( | |
| tool: DashTool.flutterTool, | |
| homeDirectory: homeDirectory, | |
| dartVersion: fakeFlutterVersion.dartSdkVersion, | |
| fs: fs, | |
| flutterChannel: fakeFlutterVersion.channel, | |
| flutterVersion: fakeFlutterVersion.getVersionString(), | |
| ); | |
| initialAnalytics.clientShowedMessage(); | |
| return Analytics.fake( | |
| tool: DashTool.flutterTool, | |
| homeDirectory: homeDirectory, | |
| dartVersion: fakeFlutterVersion.dartSdkVersion, | |
| fs: fs, | |
| flutterChannel: fakeFlutterVersion.channel, | |
| flutterVersion: fakeFlutterVersion.getVersionString(), | |
| clientIde: clientIde, | |
| enabledFeatures: enabledFeatures, | |
| ); | |
| } | |
| /// 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 lookup = <String, String>{ | |
| 'workflow': workflow, | |
| 'variableName': variableName, | |
| 'label': ?label, | |
| }; | |
| for (final e in sentEvents) { | |
| final eventData = <String, Object?>{...e.eventData}; | |
| eventData.remove('elapsedMilliseconds'); | |
| if (const DeepCollectionEquality().equals(lookup, eventData)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } |