Implement golden-file matching for integration_test on Android and iOS devices (#160484)

Work towards https://github.com/flutter/flutter/issues/143299.
Work towards https://github.com/flutter/flutter/issues/160043.

---

This PR implements, end-to-end, support for `matchesGoldenFile` when (a)
running with `package:integration_test` (b) on a device, such as an
Android emulator, Android device, iOS simulator, or iOS device, where
the _runner_ of a test file does not have process and local-file system
access.

There are multiple parts to this PR; I could make it smaller than 1K
lines, but the bulk of that is tests, and it would mean landing PRs that
are incomplete and unused, which does not seem useful - so instead here
is a quick overview of the PR's contents - questions/feedback welcome,
and I am willing to break code out or land incremental refactors if
requested.

1. Augmented `flutter_platform.dart` (used for iOS and Android), similar
to
[`flutter_web_platform.dart`](1398dc7eec/packages/flutter_tools/lib/src/test/flutter_web_platform.dart (L117-L128)),
now creates and uses
[`test_golden_comparator.dart`](https://github.com/flutter/flutter/blob/master/packages/flutter_tools/lib/src/test/test_golden_comparator.dart)
to proxy calls (coming from the VM service protocol) for golden-file
updates and comparisons to a `flutter_tester` process. A full
explanation of how (or why) it works this way is too hard to include
here, but see https://github.com/flutter/flutter/pull/160215 for more
details.
1. Added `VmServiceProxyGoldenFileComparator`, which is a currently
unused (outside of a single e2e test) comparator that forwards calls to
`compare` and `update` to the VM service protocol (of which, the other
side of this is implemented above, in `flutter_platform.dart`. The idea
is that this comparator would be used automatically when running in an
integration test on a device that requires it (similar to how web works
today), but that is **not** wired up yet and requires additional work in
`flutter_tools`.
1. Added two unit tests (of both the client and server), and a full
e2e-test using it to run `matchesGoldenFile`.
This commit is contained in:
Matan Lurey 2024-12-28 11:48:20 -08:00 committed by GitHub
parent b15625ca92
commit 4cd0e33013
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 958 additions and 22 deletions

View File

@ -151,19 +151,19 @@ Future<void> run(List<String> args) async {
} }
// TODO(dnfield): This should be injected. // TODO(dnfield): This should be injected.
final BuildInfo buildInfo = BuildInfo(
BuildMode.debug,
'',
treeShakeIcons: false,
packageConfigPath: globals.fs.path.normalize(
globals.fs.path.absolute(argResults[_kOptionPackages] as String),
),
);
exitCode = await const FlutterTestRunner().runTests( exitCode = await const FlutterTestRunner().runTests(
const TestWrapper(), const TestWrapper(),
tests.keys.map(Uri.file).toList(), tests.keys.map(Uri.file).toList(),
debuggingOptions: DebuggingOptions.enabled( debuggingOptions: DebuggingOptions.enabled(buildInfo),
BuildInfo( buildInfo: buildInfo,
BuildMode.debug,
'',
treeShakeIcons: false,
packageConfigPath: globals.fs.path.normalize(
globals.fs.path.absolute(argResults[_kOptionPackages] as String),
),
),
),
watcher: collector, watcher: collector,
enableVmService: collector != null, enableVmService: collector != null,
precompiledDillFiles: tests, precompiledDillFiles: tests,

View File

@ -1008,7 +1008,7 @@ class DebuggingOptions {
startPaused = false, startPaused = false,
dartFlags = '', dartFlags = '',
disableServiceAuthCodes = false, disableServiceAuthCodes = false,
enableDds = true, enableDds = false,
cacheStartupProfile = false, cacheStartupProfile = false,
enableSoftwareRendering = false, enableSoftwareRendering = false,
skiaDeterministicRendering = false, skiaDeterministicRendering = false,

View File

@ -3,16 +3,20 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:package_config/package_config.dart'; import 'package:package_config/package_config.dart';
import 'package:process/process.dart';
import 'package:stream_channel/stream_channel.dart'; import 'package:stream_channel/stream_channel.dart';
import 'package:test_core/src/platform.dart'; // ignore: implementation_imports import 'package:test_core/src/platform.dart'; // ignore: implementation_imports
import 'package:vm_service/vm_service.dart';
import '../base/async_guard.dart'; import '../base/async_guard.dart';
import '../base/common.dart'; import '../base/common.dart';
import '../base/file_system.dart'; import '../base/file_system.dart';
import '../base/io.dart'; import '../base/io.dart';
import '../base/logger.dart';
import '../base/process.dart'; import '../base/process.dart';
import '../build_info.dart'; import '../build_info.dart';
import '../cache.dart'; import '../cache.dart';
@ -32,6 +36,7 @@ import 'integration_test_device.dart';
import 'test_compiler.dart'; import 'test_compiler.dart';
import 'test_config.dart'; import 'test_config.dart';
import 'test_device.dart'; import 'test_device.dart';
import 'test_golden_comparator.dart';
import 'test_time_recorder.dart'; import 'test_time_recorder.dart';
import 'watcher.dart'; import 'watcher.dart';
@ -53,6 +58,10 @@ FlutterPlatform installHook({
TestWrapper testWrapper = const TestWrapper(), TestWrapper testWrapper = const TestWrapper(),
required String shellPath, required String shellPath,
required DebuggingOptions debuggingOptions, required DebuggingOptions debuggingOptions,
required BuildInfo buildInfo,
required FileSystem fileSystem,
required Logger logger,
required ProcessManager processManager,
TestWatcher? watcher, TestWatcher? watcher,
bool enableVmService = false, bool enableVmService = false,
bool machine = false, bool machine = false,
@ -69,7 +78,6 @@ FlutterPlatform installHook({
String? integrationTestUserIdentifier, String? integrationTestUserIdentifier,
TestTimeRecorder? testTimeRecorder, TestTimeRecorder? testTimeRecorder,
TestCompilerNativeAssetsBuilder? nativeAssetsBuilder, TestCompilerNativeAssetsBuilder? nativeAssetsBuilder,
BuildInfo? buildInfo,
}) { }) {
assert( assert(
enableVmService || enableVmService ||
@ -101,6 +109,9 @@ FlutterPlatform installHook({
testTimeRecorder: testTimeRecorder, testTimeRecorder: testTimeRecorder,
nativeAssetsBuilder: nativeAssetsBuilder, nativeAssetsBuilder: nativeAssetsBuilder,
buildInfo: buildInfo, buildInfo: buildInfo,
fileSystem: fileSystem,
logger: logger,
processManager: processManager,
); );
platformPluginRegistration(platform); platformPluginRegistration(platform);
return platform; return platform;
@ -289,6 +300,10 @@ class FlutterPlatform extends PlatformPlugin {
FlutterPlatform({ FlutterPlatform({
required this.shellPath, required this.shellPath,
required this.debuggingOptions, required this.debuggingOptions,
required this.buildInfo,
required this.logger,
required FileSystem fileSystem,
required ProcessManager processManager,
this.watcher, this.watcher,
this.enableVmService, this.enableVmService,
this.machine, this.machine,
@ -304,9 +319,19 @@ class FlutterPlatform extends PlatformPlugin {
this.integrationTestUserIdentifier, this.integrationTestUserIdentifier,
this.testTimeRecorder, this.testTimeRecorder,
this.nativeAssetsBuilder, this.nativeAssetsBuilder,
this.buildInfo,
this.shutdownHooks, this.shutdownHooks,
}); }) {
_testGoldenComparator = TestGoldenComparator(
flutterTesterBinPath: shellPath,
compilerFactory:
() =>
compiler ??
TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
fileSystem: fileSystem,
logger: logger,
processManager: processManager,
);
}
final String shellPath; final String shellPath;
final DebuggingOptions debuggingOptions; final DebuggingOptions debuggingOptions;
@ -323,7 +348,8 @@ class FlutterPlatform extends PlatformPlugin {
final String? icudtlPath; final String? icudtlPath;
final TestTimeRecorder? testTimeRecorder; final TestTimeRecorder? testTimeRecorder;
final TestCompilerNativeAssetsBuilder? nativeAssetsBuilder; final TestCompilerNativeAssetsBuilder? nativeAssetsBuilder;
final BuildInfo? buildInfo; final BuildInfo buildInfo;
final Logger logger;
final ShutdownHooks? shutdownHooks; final ShutdownHooks? shutdownHooks;
/// The device to run the test on for Integration Tests. /// The device to run the test on for Integration Tests.
@ -332,6 +358,7 @@ class FlutterPlatform extends PlatformPlugin {
/// Tester; otherwise it will run as a Integration Test on this device. /// Tester; otherwise it will run as a Integration Test on this device.
final Device? integrationTestDevice; final Device? integrationTestDevice;
bool get _isIntegrationTest => integrationTestDevice != null; bool get _isIntegrationTest => integrationTestDevice != null;
late final TestGoldenComparator _testGoldenComparator;
final String? integrationTestUserIdentifier; final String? integrationTestUserIdentifier;
@ -375,7 +402,9 @@ class FlutterPlatform extends PlatformPlugin {
return controller.suite; return controller.suite;
} }
StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) { /// Used as an implementation detail for [load]ing a test suite.
@visibleForTesting
StreamChannel<Object?> loadChannel(String path, SuitePlatform platform) {
if (_testCount > 0) { if (_testCount > 0) {
// Fail if there will be a port conflict. // Fail if there will be a port conflict.
if (debuggingOptions.hostVmServicePort != null) { if (debuggingOptions.hostVmServicePort != null) {
@ -481,15 +510,64 @@ class FlutterPlatform extends PlatformPlugin {
); );
} }
void _handleStartedDevice(Uri? uri, int testCount) { void _handleStartedDevice({required Uri? uri, required int testCount, required String testPath}) {
if (uri != null) { if (uri != null) {
globals.printTrace('test $testCount: VM Service uri is available at $uri'); globals.printTrace('test $testCount: VM Service uri is available at $uri');
if (_isIntegrationTest) {
_listenToVmServiceForGoldens(uri: uri, testPath: testPath);
}
} else { } else {
globals.printTrace('test $testCount: VM Service uri is not available'); globals.printTrace('test $testCount: VM Service uri is not available');
} }
watcher?.handleStartedDevice(uri); watcher?.handleStartedDevice(uri);
} }
static const String _kEventName = 'integration_test.VmServiceProxyGoldenFileComparator';
static const String _kExtension = 'ext.$_kEventName';
Future<void> _listenToVmServiceForGoldens({required Uri uri, required String testPath}) async {
final Uri goldensBaseUri = Uri.parse(testPath);
final FlutterVmService vmService = await connectToVmService(uri, logger: logger);
final IsolateRef testAppIsolate = await vmService.findExtensionIsolate(_kExtension);
await vmService.service.streamListen(_kEventName);
vmService.service.onEvent(_kEventName).listen((Event e) async {
if (!const <String>['compare', 'update'].contains(e.extensionKind)) {
throw StateError('Unexpected command: "${e.extensionKind}".');
}
final Map<String, Object?>? data = e.extensionData?.data;
if (data == null) {
throw StateError('Expected VM service data, but got null.');
}
final int id = data['id']! as int;
final Uri relativePath = Uri.parse(data['path']! as String);
final Uint8List bytes = base64.decode(data['bytes']! as String);
final Map<String, Object?> args;
if (e.extensionKind == 'update') {
switch (await _testGoldenComparator.update(goldensBaseUri, bytes, relativePath)) {
case TestGoldenUpdateDone():
args = <String, Object?>{'result': true};
case TestGoldenUpdateError(error: final String error):
args = <String, Object?>{'error': error};
}
} else {
switch (await _testGoldenComparator.compare(goldensBaseUri, bytes, relativePath)) {
case TestGoldenComparisonDone(matched: final bool matched):
args = <String, Object?>{'result': matched};
case TestGoldenComparisonError(error: final String error):
args = <String, Object?>{'error': error};
}
}
await vmService.callMethodWrapper(
_kExtension,
isolateId: testAppIsolate.id,
args: <String, Object?>{'id': id, ...args},
);
});
}
Future<_AsyncError?> _startTest( Future<_AsyncError?> _startTest(
String testPath, String testPath,
StreamChannel<dynamic> testHarnessChannel, StreamChannel<dynamic> testHarnessChannel,
@ -625,7 +703,11 @@ class FlutterPlatform extends PlatformPlugin {
// This future may depend on [_handleStartedDevice] having been called // This future may depend on [_handleStartedDevice] having been called
remoteChannelCompleter.future, remoteChannelCompleter.future,
testDevice.vmServiceUri.then<void>((Uri? processVmServiceUri) { testDevice.vmServiceUri.then<void>((Uri? processVmServiceUri) {
_handleStartedDevice(processVmServiceUri, ourTestCount); _handleStartedDevice(
uri: processVmServiceUri,
testCount: ourTestCount,
testPath: testPath,
);
}), }),
], ],
// If [remoteChannelCompleter.future] errors, we may never get the // If [remoteChannelCompleter.future] errors, we may never get the
@ -727,8 +809,7 @@ class FlutterPlatform extends PlatformPlugin {
testUrl: testUrl, testUrl: testUrl,
testConfigFile: findTestConfigFile(globals.fs.file(testUrl), globals.logger), testConfigFile: findTestConfigFile(globals.fs.file(testUrl), globals.logger),
// This MUST be a file URI. // This MUST be a file URI.
packageConfigUri: packageConfigUri: globals.fs.path.toUri(buildInfo.packageConfigPath),
buildInfo != null ? globals.fs.path.toUri(buildInfo!.packageConfigPath) : null,
host: host!, host: host!,
updateGoldens: updateGoldens!, updateGoldens: updateGoldens!,
flutterTestDep: packageConfig['flutter_test'] != null, flutterTestDep: packageConfig['flutter_test'] != null,

View File

@ -63,7 +63,7 @@ abstract class FlutterTestRunner {
String? integrationTestUserIdentifier, String? integrationTestUserIdentifier,
TestTimeRecorder? testTimeRecorder, TestTimeRecorder? testTimeRecorder,
TestCompilerNativeAssetsBuilder? nativeAssetsBuilder, TestCompilerNativeAssetsBuilder? nativeAssetsBuilder,
BuildInfo? buildInfo, required BuildInfo buildInfo,
}); });
/// Runs tests using the experimental strategy of spawning each test in a /// Runs tests using the experimental strategy of spawning each test in a
@ -130,7 +130,7 @@ class _FlutterTestRunnerImpl implements FlutterTestRunner {
String? integrationTestUserIdentifier, String? integrationTestUserIdentifier,
TestTimeRecorder? testTimeRecorder, TestTimeRecorder? testTimeRecorder,
TestCompilerNativeAssetsBuilder? nativeAssetsBuilder, TestCompilerNativeAssetsBuilder? nativeAssetsBuilder,
BuildInfo? buildInfo, required BuildInfo buildInfo,
}) async { }) async {
// Configure package:test to use the Flutter engine for child processes. // Configure package:test to use the Flutter engine for child processes.
final String flutterTesterBinPath = globals.artifacts!.getArtifactPath(Artifact.flutterTester); final String flutterTesterBinPath = globals.artifacts!.getArtifactPath(Artifact.flutterTester);
@ -236,6 +236,9 @@ class _FlutterTestRunnerImpl implements FlutterTestRunner {
testTimeRecorder: testTimeRecorder, testTimeRecorder: testTimeRecorder,
nativeAssetsBuilder: nativeAssetsBuilder, nativeAssetsBuilder: nativeAssetsBuilder,
buildInfo: buildInfo, buildInfo: buildInfo,
fileSystem: globals.fs,
logger: globals.logger,
processManager: globals.processManager,
); );
try { try {

View File

@ -2,8 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'dart:io' as io;
import 'package:file/memory.dart'; import 'package:file/memory.dart';
import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/application_package.dart';
import 'package:flutter_tools/src/artifacts.dart';
import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/logger.dart';
@ -14,8 +18,12 @@ import 'package:flutter_tools/src/flutter_manifest.dart';
import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/project.dart';
import 'package:flutter_tools/src/test/flutter_platform.dart'; import 'package:flutter_tools/src/test/flutter_platform.dart';
import 'package:flutter_tools/src/test/test_compiler.dart';
import 'package:flutter_tools/src/vmservice.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test/fake.dart'; import 'package:test/fake.dart';
import 'package:test_core/backend.dart'; import 'package:test_core/backend.dart';
import 'package:vm_service/src/vm_service.dart';
import '../src/common.dart'; import '../src/common.dart';
import '../src/context.dart'; import '../src/context.dart';
@ -44,6 +52,10 @@ void main() {
shellPath: '/', shellPath: '/',
debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, hostVmServicePort: 1234), debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, hostVmServicePort: 1234),
enableVmService: false, enableVmService: false,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
); );
flutterPlatform.loadChannel('test1.dart', fakeSuitePlatform); flutterPlatform.loadChannel('test1.dart', fakeSuitePlatform);
@ -67,6 +79,10 @@ void main() {
shellPath: '/', shellPath: '/',
precompiledDillPath: 'example.dill', precompiledDillPath: 'example.dill',
enableVmService: false, enableVmService: false,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
); );
flutterPlatform.loadChannel('test1.dart', fakeSuitePlatform); flutterPlatform.loadChannel('test1.dart', fakeSuitePlatform);
@ -93,6 +109,10 @@ void main() {
flutterProject: _FakeFlutterProject(), flutterProject: _FakeFlutterProject(),
host: InternetAddress.anyIPv4, host: InternetAddress.anyIPv4,
updateGoldens: false, updateGoldens: false,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
); );
await expectLater( await expectLater(
@ -133,6 +153,10 @@ void main() {
host: InternetAddress.anyIPv4, host: InternetAddress.anyIPv4,
updateGoldens: false, updateGoldens: false,
shutdownHooks: shutdownHooks, shutdownHooks: shutdownHooks,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
); );
await expectLater( await expectLater(
@ -156,6 +180,10 @@ void main() {
() => installHook( () => installHook(
shellPath: 'abc', shellPath: 'abc',
debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, startPaused: true), debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, startPaused: true),
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
), ),
throwsAssertionError, throwsAssertionError,
); );
@ -168,6 +196,10 @@ void main() {
startPaused: true, startPaused: true,
hostVmServicePort: 123, hostVmServicePort: 123,
), ),
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
), ),
throwsAssertionError, throwsAssertionError,
); );
@ -193,6 +225,10 @@ void main() {
platformPluginRegistration: (FlutterPlatform platform) { platformPluginRegistration: (FlutterPlatform platform) {
capturedPlatform = platform; capturedPlatform = platform;
}, },
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: FakeProcessManager.empty(),
logger: BufferLogger.test(),
); );
expect(identical(capturedPlatform, flutterPlatform), equals(true)); expect(identical(capturedPlatform, flutterPlatform), equals(true));
@ -247,6 +283,254 @@ void main() {
}); });
}); });
}); });
group('proxies goldenFileComparator using the VM service driver', () {
late SuitePlatform fakeSuitePlatform;
late MemoryFileSystem fileSystem;
late Artifacts artifacts;
late FlutterProject flutterProject;
late FakeProcessManager processManager;
late BufferLogger logger;
late TestCompiler testCompiler;
late _FakeFlutterVmService flutterVmService;
late Completer<void> testCompleter;
setUp(() {
fakeSuitePlatform = SuitePlatform(Runtime.vm);
fileSystem = MemoryFileSystem.test();
fileSystem.file('.dart_tool/package_config.json')
..createSync(recursive: true)
..writeAsStringSync('{"configVersion":2,"packages":[]}');
artifacts = Artifacts.test(fileSystem: fileSystem);
flutterProject = FlutterProject.fromDirectoryTest(fileSystem.systemTempDirectory);
testCompleter = Completer<void>();
processManager = FakeProcessManager.empty();
logger = BufferLogger.test();
testCompiler = _FakeTestCompiler();
flutterVmService = _FakeFlutterVmService();
});
tearDown(() {
printOnFailure(logger.errorText);
});
void addFlutterTesterDeviceExpectation() {
processManager.addCommand(
FakeCommand(
command: const <String>[
'flutter_tester',
'--disable-vm-service',
'--enable-checked-mode',
'--verify-entry-points',
'--enable-software-rendering',
'--skia-deterministic-rendering',
'--enable-dart-profiling',
'--non-interactive',
'--use-test-fonts',
'--disable-asset-fonts',
'--packages=.dart_tool/package_config.json',
'',
],
exitCode: -9,
completer: testCompleter,
),
);
}
testUsingContext(
'should not listen in a non-integration test',
() async {
addFlutterTesterDeviceExpectation();
const Device? notAnIntegrationTest = null;
final FlutterPlatform flutterPlatform = FlutterPlatform(
debuggingOptions: DebuggingOptions.disabled(BuildInfo.debug),
shellPath: 'flutter_tester',
enableVmService: false,
// ignore: avoid_redundant_argument_values
integrationTestDevice: notAnIntegrationTest,
flutterProject: flutterProject,
host: InternetAddress.anyIPv4,
updateGoldens: false,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: processManager,
logger: BufferLogger.test(),
);
flutterPlatform.compiler = testCompiler;
// Simulate the test immediately completing.
testCompleter.complete();
final StreamChannel<Object?> channel = flutterPlatform.loadChannel(
'test1.dart',
fakeSuitePlatform,
);
// Without draining, the sink will never complete.
unawaited(channel.stream.drain<void>());
await expectLater(channel.sink.done, completes);
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => processManager,
Logger: () => logger,
VMServiceConnector: () => (_) => throw UnimplementedError(),
},
);
// This is not a complete test of all the possible cases supported by the
// golden-file integration, which is a complex multi-process implementation
// that lives across multiple packages and files.
//
// Instead, this is a unit-test based smoke test that the overall flow works
// as expected to give a quicker turn-around signal that either the test
// should be updated, or the process was broken; run an integration_test on
// an Android or iOS device or emulator/simulator that takes screenshots
// and compares them with matchesGoldenFile for a full e2e-test of the
// entire workflow.
testUsingContext(
'should listen in an integration test',
() async {
processManager.addCommand(
const FakeCommand(
command: <String>[
'flutter_tester',
'--disable-vm-service',
'--non-interactive',
'--packages=.dart_tool/package_config.json',
'',
],
stdout: '{"success": true}\n',
),
);
addFlutterTesterDeviceExpectation();
final FlutterPlatform flutterPlatform = FlutterPlatform(
debuggingOptions: DebuggingOptions.disabled(BuildInfo.debug),
shellPath: 'flutter_tester',
enableVmService: false,
flutterProject: flutterProject,
integrationTestDevice: _WorkingDevice(),
host: InternetAddress.anyIPv4,
updateGoldens: false,
buildInfo: BuildInfo.debug,
fileSystem: fileSystem,
processManager: processManager,
logger: BufferLogger.test(),
);
flutterPlatform.compiler = testCompiler;
final StreamChannel<Object?> channel = flutterPlatform.loadChannel(
'test1.dart',
fakeSuitePlatform,
);
// Responds to update events.
flutterVmService.service.onExtensionEventController.add(
Event(
extensionData: ExtensionData.parse(<String, Object?>{
'id': 1,
'path': 'foo',
'bytes': '',
}),
extensionKind: 'update',
),
);
// Wait for tiny async tasks to complete.
await pumpEventQueue();
await flutterVmService.service.onExtensionEventController.close();
final (String event, String? isolateId, Map<String, Object?>? data) =
flutterVmService.callMethodWrapperInvocation!;
expect(event, 'ext.integration_test.VmServiceProxyGoldenFileComparator');
expect(isolateId, null);
expect(data, <String, Object?>{'id': 1, 'result': true});
// Without draining, the sink will never complete.
unawaited(channel.stream.drain<void>());
// Allow the test to finish.
testCompleter.complete();
await expectLater(channel.sink.done, completes);
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => processManager,
Logger: () => logger,
VMServiceConnector:
() =>
(
Uri httpUri, {
ReloadSources? reloadSources,
Restart? restart,
CompileExpression? compileExpression,
GetSkSLMethod? getSkSLMethod,
FlutterProject? flutterProject,
PrintStructuredErrorLogMethod? printStructuredErrorLogMethod,
io.CompressionOptions? compression,
Device? device,
Logger? logger,
}) async => flutterVmService,
ApplicationPackageFactory: _FakeApplicationPackageFactory.new,
Artifacts: () => artifacts,
},
);
});
}
class _FakeFlutterVmService extends Fake implements FlutterVmService {
@override
Future<IsolateRef> findExtensionIsolate(String extensionName) async {
return IsolateRef();
}
@override
final _FakeVmService service = _FakeVmService();
(String, String?, Map<String, Object?>?)? callMethodWrapperInvocation;
@override
Future<Response?> callMethodWrapper(
String method, {
String? isolateId,
Map<String, Object?>? args,
}) async {
callMethodWrapperInvocation = (method, isolateId, args);
return Response();
}
}
class _FakeVmService extends Fake implements VmService {
String? lastStreamListenId;
@override
Future<Success> streamListen(String streamId) async {
lastStreamListenId = streamId;
return Success();
}
final StreamController<Event> onExtensionEventController = StreamController<Event>();
@override
Stream<Event> get onExtensionEvent => const Stream<Event>.empty();
@override
Stream<Event> onEvent(String streamId) {
return onExtensionEventController.stream;
}
@override
Future<void> get onDone => onExtensionEventController.done;
}
class _FakeTestCompiler extends Fake implements TestCompiler {
@override
Future<String?> compile(Uri mainDart) async => '';
} }
class _UnstartableDevice extends Fake implements Device { class _UnstartableDevice extends Fake implements Device {

View File

@ -0,0 +1,43 @@
// 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.
@Tags(<String>['reduced-test-set'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:integration_test_example/main.dart' as app;
/// To run:
///
/// ```sh
/// # Be in this directory
/// cd dev/packages/integration_test/example
///
/// flutter test integration_test/matches_golden_test.dart
/// ```
///
/// To run on a particular device, see `flutter -d`.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// TODO(matanlurey): Make this automatic as part of the bootstrap.
VmServiceProxyGoldenFileComparator.useIfRunningOnDevice();
testWidgets('can use matchesGoldenFile with integration_test', (WidgetTester tester) async {
// Build our app and trigger a frame.
app.main();
// TODO(matanlurey): Is this necessary?
await tester.pumpAndSettle();
// Take a screenshot.
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('integration_test_matches_golden_file.png'),
);
});
}

View File

@ -24,6 +24,8 @@ import 'src/callback.dart' as driver_actions;
import 'src/channel.dart'; import 'src/channel.dart';
import 'src/extension.dart'; import 'src/extension.dart';
export 'src/vm_service_golden_client.dart';
const String _success = 'success'; const String _success = 'success';
/// Whether results should be reported to the native side over the method /// Whether results should be reported to the native side over the method

View File

@ -0,0 +1,226 @@
// 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.
// Examples can assume:
// import 'dart:developer' as dev;
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as dev;
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart';
/// Compares image pixels against a golden image file on the host system.
///
/// This comparator will send a request, using the VM service protocol, to a
/// host script (i.e. the _driver_ script, running in a Dart VM on the host
/// desktop OS), which will then forward the comparison to a concrete
/// [GoldenFileComparator].
///
/// To use, run [useIfRunningOnDevice] in the `main()` of a test file or similar:
///
/// ```dart
/// import 'package:integration_test/integration_test.dart';
///
/// void main() {
/// VmServiceProxyGoldenFileComparator.useIfRunningOnDevice();
///
/// // Actual tests and such below.
/// }
/// ```
///
/// When either [compare] or [update] is called, the following event is sent
/// with [dev.postEvent]:
///
/// ```dart
/// dev.postEvent('compare' /* or 'update' */, <String, Object?>{
/// 'id': 1001, // a valid unique integer, often incrementing;
/// 'path': 'path/to/image.png', // golden key created by matchesGoldenFile;
/// 'bytes': '...base64encoded', // base64 encoded bytes representing the current image.
/// }, stream: 'integration_test.VmServiceProxyGoldenFileComparator');
/// ```
///
/// The comparator expects a response at the service extension
/// `ext.integration_test.VmServiceProxyGoldenFileComparator` that is either
/// of the following formats:
///
/// ```dart
/// <String, Object?>{
/// 'error': 'Description of why the operation failed'
/// }
/// ```
///
/// or:
///
/// ```dart
/// <String, Object?>{
/// 'result': true /* or possibly false, in the case of 'compare' calls */
/// }
/// ```
///
/// See also:
///
/// * [matchesGoldenFile], the function that invokes the comparator.
@experimental
final class VmServiceProxyGoldenFileComparator extends GoldenFileComparator {
VmServiceProxyGoldenFileComparator._() : _postEvent = dev.postEvent {
dev.registerExtension(_kServiceName, (_, Map<String, String> parameters) {
return handleEvent(parameters);
});
}
/// Creates an instance of [VmServiceProxyGoldenFileComparator] for internal testing.
///
/// @nodoc
@visibleForTesting
VmServiceProxyGoldenFileComparator.forTesting(this._postEvent);
static bool get _isRunningOnHost {
if (kIsWeb) {
return false;
}
return !io.Platform.isAndroid && !io.Platform.isIOS;
}
static void _assertNotRunningOnFuchsia() {
if (!kIsWeb && io.Platform.isFuchsia) {
throw UnsupportedError('Golden testing with integration_test does not support Fuchsia.');
}
}
/// Conditionally sets [goldenFileComparator] to [VmServiceProxyGoldenFileComparator].
///
/// If running on a non-mobile non-web platform (i.e. desktop), this method has no effect.
static void useIfRunningOnDevice() {
if (_isRunningOnHost) {
return;
}
_assertNotRunningOnFuchsia();
goldenFileComparator = _kInstance;
}
static final GoldenFileComparator _kInstance = VmServiceProxyGoldenFileComparator._();
static const String _kServiceName = 'ext.$_kEventName';
static const String _kEventName = 'integration_test.VmServiceProxyGoldenFileComparator';
final void Function(String, Map<Object?, Object?>, {String stream}) _postEvent;
/// Handles the received method and parameters as an incoming event.
///
/// Each event is treated as if it were received by the Dart developer
/// extension protocol; this method is public only to be able to write unit
/// tests that do not have to bring up and use a VM service.
///
/// @nodoc
@visibleForTesting
Future<dev.ServiceExtensionResponse> handleEvent(Map<String, String> parameters) async {
// Treat the method as the ID number of the pending request.
final String? methodIdString = parameters['id'];
if (methodIdString == null) {
return dev.ServiceExtensionResponse.error(
dev.ServiceExtensionResponse.extensionError,
'Required parameter "id" not present in response.',
);
}
final int? methodId = int.tryParse(methodIdString);
if (methodId == null) {
return dev.ServiceExtensionResponse.error(
dev.ServiceExtensionResponse.extensionError,
'Required parameter "id" not a valid integer: "$methodIdString".',
);
}
final Completer<_Result>? completer = _pendingRequests[methodId];
if (completer == null) {
return dev.ServiceExtensionResponse.error(
dev.ServiceExtensionResponse.extensionError,
'No pending request with method ID "$methodIdString".',
);
}
assert(!completer.isCompleted, 'Can never occur, as the completer should be removed');
final String? error = parameters['error'];
if (error != null) {
completer.complete(_Failure(error));
return dev.ServiceExtensionResponse.result('{}');
}
final String? result = parameters['result'];
if (result == null) {
return dev.ServiceExtensionResponse.error(
dev.ServiceExtensionResponse.invalidParams,
'Required parameter "result" not present in response.',
);
}
if (bool.tryParse(result) case final bool result) {
completer.complete(_Success(result));
return dev.ServiceExtensionResponse.result('{}');
} else {
return dev.ServiceExtensionResponse.error(
dev.ServiceExtensionResponse.invalidParams,
'Required parameter "result" not a valid boolean: "$result".',
);
}
}
int _nextId = 0;
final Map<int, Completer<_Result>> _pendingRequests = <int, Completer<_Result>>{};
Future<_Result> _postAndWait(
Uint8List imageBytes,
Uri golden, {
required String operation,
}) async {
final int nextId = ++_nextId;
assert(!_pendingRequests.containsKey(nextId));
final Completer<_Result> completer = Completer<_Result>();
_postEvent(operation, <String, Object?>{
'id': nextId,
'path': '$golden',
'bytes': base64.encode(imageBytes),
}, stream: _kEventName);
_pendingRequests[nextId] = completer;
completer.future.whenComplete(() {
_pendingRequests.remove(nextId);
});
return completer.future;
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
return switch (await _postAndWait(imageBytes, golden, operation: 'compare')) {
_Success(:final bool result) => result,
_Failure(:final String error) => Future<bool>.error(error),
};
}
@override
Future<void> update(Uri golden, Uint8List imageBytes) async {
final _Result result = await _postAndWait(imageBytes, golden, operation: 'update');
if (result is _Failure) {
return Future<void>.error(result.error);
}
}
}
// These wrapper classes allow us to use a Completer to indicate both a failed
// response and a successful response, without making a call of completeError
// within handleEvent, which is difficult or impossible to use correctly because
// of the semantics of error zones.
//
// Of course, this is a private implementation detail, others are welcome to try
// an alternative approach that might simplify the code above, but it's probably
// not worth it.
sealed class _Result {}
final class _Success implements _Result {
_Success(this.result);
final bool result;
}
final class _Failure implements _Result {
_Failure(this.error);
final String error;
}

View File

@ -0,0 +1,297 @@
// 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:convert';
import 'dart:developer' as dev;
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart' as integration_test;
void main() {
setUp(() {
// Ensure that we reset to a throwing comparator by default.
goldenFileComparator = const _NullGoldenFileComparator();
});
group('useIfRunningOnDevice', () {
test('is skipped on the web', () {
integration_test.VmServiceProxyGoldenFileComparator.useIfRunningOnDevice();
expect(goldenFileComparator, isInstanceOf<_NullGoldenFileComparator>());
}, testOn: 'js');
test('is skipped on desktop platforms', () {
integration_test.VmServiceProxyGoldenFileComparator.useIfRunningOnDevice();
expect(goldenFileComparator, isInstanceOf<_NullGoldenFileComparator>());
}, testOn: 'windows || mac-os || linux');
test('is set on mobile platforms', () {
integration_test.VmServiceProxyGoldenFileComparator.useIfRunningOnDevice();
expect(
goldenFileComparator,
isInstanceOf<integration_test.VmServiceProxyGoldenFileComparator>(),
);
}, testOn: 'ios || android');
});
group('handleEvent', () {
late integration_test.VmServiceProxyGoldenFileComparator goldenFileComparator;
setUp(() {
goldenFileComparator = integration_test.VmServiceProxyGoldenFileComparator.forTesting(
(String operation, Map<Object?, Object?> params, {String stream = ''}) {},
);
});
test('"id" must be provided', () async {
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'result': 'true'},
);
expect(response.errorDetail, contains('Required parameter "id" not present in response'));
});
test('"id" must be an integer', () async {
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': 'not-an-integer', 'result': 'true'},
);
expect(
response.errorDetail,
stringContainsInOrder(<String>[
'Required parameter "id" not a valid integer',
'not-an-integer',
]),
);
});
test('"id" must match a pending request (never occurred)', () async {
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': '12345', 'result': 'true'},
);
expect(
response.errorDetail,
stringContainsInOrder(<String>['No pending request with method ID', '12345']),
);
});
test('"id" must match a pending request (already occurred)', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
goldenFileComparator.update(Uri(path: 'some-file'), Uint8List(0));
dev.ServiceExtensionResponse response;
response = await goldenFileComparator.handleEvent(<String, String>{
'id': '$nextId',
'result': 'true',
});
expect(response.errorDetail, isNull);
response = await goldenFileComparator.handleEvent(<String, String>{
'id': '$nextId',
'result': 'true',
});
expect(
response.errorDetail,
stringContainsInOrder(<String>['No pending request with method ID', '1']),
);
});
test('requests that contain "error" completes it as an error', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
expect(
goldenFileComparator.compare(Uint8List(0), Uri(path: 'some-file')),
throwsA(contains('We did a bad')),
);
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': '$nextId', 'error': 'We did a bad'},
);
expect(response.errorDetail, isNull);
expect(response.result, '{}');
});
test('requests that do not contain "error" return an empty response', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
goldenFileComparator.update(Uri(path: 'some-file'), Uint8List(0));
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': '$nextId', 'result': 'true'},
);
expect(response.errorDetail, isNull);
expect(response.result, '{}');
});
test('"result" must be provided if "error" is omitted', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
goldenFileComparator.update(Uri(path: 'some-file'), Uint8List(0));
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': '$nextId'},
);
expect(response.errorDetail, contains('Required parameter "result" not present in response'));
});
test('"result" must be a boolean', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
goldenFileComparator.update(Uri(path: 'some-file'), Uint8List(0));
final dev.ServiceExtensionResponse response = await goldenFileComparator.handleEvent(
<String, String>{'id': '$nextId', 'result': 'not-a-boolean'},
);
expect(
response.errorDetail,
stringContainsInOrder(<String>[
'Required parameter "result" not a valid boolean',
'not-a-boolean',
]),
);
});
group('compare', () {
late integration_test.VmServiceProxyGoldenFileComparator goldenFileComparator;
late List<(String, Map<Object?, Object?>)> postedEvents;
setUp(() {
postedEvents = <(String, Map<Object?, Object?>)>[];
goldenFileComparator = integration_test.VmServiceProxyGoldenFileComparator.forTesting((
String operation,
Map<Object?, Object?> params, {
String stream = '',
}) {
postedEvents.add((operation, params));
});
});
test('posts an event and returns true', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
final Uint8List bytes = Uint8List.fromList(<int>[1, 2, 3, 4, 5]);
expect(goldenFileComparator.compare(bytes, Uri(path: 'golden-path')), completion(true));
await goldenFileComparator.handleEvent(<String, String>{'id': '$nextId', 'result': 'true'});
final (String event, Map<Object?, Object?> params) = postedEvents.single;
expect(event, 'compare');
expect(params, <Object?, Object?>{
'id': nextId,
'path': 'golden-path',
'bytes': base64.encode(bytes),
});
});
test('posts an event and returns false', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
final Uint8List bytes = Uint8List.fromList(<int>[1, 2, 3, 4, 5]);
expect(goldenFileComparator.compare(bytes, Uri(path: 'golden-path')), completion(false));
await goldenFileComparator.handleEvent(<String, String>{
'id': '$nextId',
'result': 'false',
});
final (String event, Map<Object?, Object?> params) = postedEvents.single;
expect(event, 'compare');
expect(params, <Object?, Object?>{
'id': nextId,
'path': 'golden-path',
'bytes': base64.encode(bytes),
});
});
test('posts an event and returns an error', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
final Uint8List bytes = Uint8List.fromList(<int>[1, 2, 3, 4, 5]);
expect(
goldenFileComparator.compare(bytes, Uri(path: 'golden-path')),
throwsA(contains('We did a bad')),
);
await goldenFileComparator.handleEvent(<String, String>{
'id': '$nextId',
'error': 'We did a bad',
});
final (String event, Map<Object?, Object?> params) = postedEvents.single;
expect(event, 'compare');
expect(params, <Object?, Object?>{
'id': nextId,
'path': 'golden-path',
'bytes': base64.encode(bytes),
});
});
});
group('update', () {
late integration_test.VmServiceProxyGoldenFileComparator goldenFileComparator;
late List<(String, Map<Object?, Object?>)> postedEvents;
setUp(() {
postedEvents = <(String, Map<Object?, Object?>)>[];
goldenFileComparator = integration_test.VmServiceProxyGoldenFileComparator.forTesting((
String operation,
Map<Object?, Object?> params, {
String stream = '',
}) {
postedEvents.add((operation, params));
});
});
test('posts an event and returns', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
final Uint8List bytes = Uint8List.fromList(<int>[1, 2, 3, 4, 5]);
expect(goldenFileComparator.update(Uri(path: 'golden-path'), bytes), completes);
await goldenFileComparator.handleEvent(<String, String>{'id': '$nextId', 'result': 'true'});
final (String event, Map<Object?, Object?> params) = postedEvents.single;
expect(event, 'update');
expect(params, <Object?, Object?>{
'id': nextId,
'path': 'golden-path',
'bytes': base64.encode(bytes),
});
});
test('posts an event and returns an error', () async {
// This is based on an implementation detail of knowing how IDs are generated.
const int nextId = 1;
final Uint8List bytes = Uint8List.fromList(<int>[1, 2, 3, 4, 5]);
expect(
goldenFileComparator.update(Uri(path: 'golden-path'), bytes),
throwsA(contains('We did a bad')),
);
await goldenFileComparator.handleEvent(<String, String>{
'id': '$nextId',
'error': 'We did a bad',
});
final (String event, Map<Object?, Object?> params) = postedEvents.single;
expect(event, 'update');
expect(params, <Object?, Object?>{
'id': nextId,
'path': 'golden-path',
'bytes': base64.encode(bytes),
});
});
});
});
}
final class _NullGoldenFileComparator with Fake implements GoldenFileComparator {
const _NullGoldenFileComparator();
}