// 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'; /// 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' */, { /// '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 /// { /// 'error': 'Description of why the operation failed' /// } /// ``` /// /// or: /// /// ```dart /// { /// 'result': true /* or possibly false, in the case of 'compare' calls */ /// } /// ``` /// /// See also: /// /// * [matchesGoldenFile], the function that invokes the comparator. final class VmServiceProxyGoldenFileComparator extends GoldenFileComparator { VmServiceProxyGoldenFileComparator._() : _postEvent = dev.postEvent { dev.registerExtension(_kServiceName, (_, Map 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, {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 handleEvent(Map 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> _pendingRequests = >{}; Future<_Result> _postAndWait( Uint8List imageBytes, Uri golden, { required String operation, }) async { final int nextId = ++_nextId; assert(!_pendingRequests.containsKey(nextId)); final completer = Completer<_Result>(); _postEvent(operation, { 'id': nextId, 'path': '$golden', 'bytes': base64.encode(imageBytes), }, stream: _kEventName); _pendingRequests[nextId] = completer; completer.future.whenComplete(() { _pendingRequests.remove(nextId); }); return completer.future; } @override Future compare(Uint8List imageBytes, Uri golden) async { return switch (await _postAndWait(imageBytes, golden, operation: 'compare')) { _Success(:final bool result) => result, _Failure(:final String error) => Future.error(error), }; } @override Future update(Uri golden, Uint8List imageBytes) async { final _Result result = await _postAndWait(imageBytes, golden, operation: 'update'); if (result is _Failure) { return Future.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; }