// 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:typed_data'; import 'package:async/async.dart'; import 'package:http_multi_server/http_multi_server.dart'; import 'package:mime/mime.dart' as mime; import 'package:package_config/package_config.dart'; import 'package:pool/pool.dart'; import 'package:process/process.dart'; import 'package:shelf/shelf.dart' as shelf; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:test_core/src/platform.dart'; // ignore: implementation_imports import 'package:web_socket_channel/web_socket_channel.dart'; import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../build_info.dart'; import '../cache.dart'; import '../convert.dart'; import '../dart/package_map.dart'; import '../project.dart'; import '../web/bootstrap.dart'; import '../web/chrome.dart'; import '../web/compile.dart'; import '../web/memory_fs.dart'; import '../web/web_constants.dart'; import 'test_compiler.dart'; import 'test_golden_comparator.dart'; import 'test_time_recorder.dart'; shelf.Handler createDirectoryHandler(Directory directory, {required bool crossOriginIsolated}) { final resolver = mime.MimeTypeResolver(); final FileSystem fileSystem = directory.fileSystem; return (shelf.Request request) async { String uriPath = request.requestedUri.path; // Strip any leading slashes if (uriPath.startsWith('/')) { uriPath = uriPath.substring(1); } final String filePath = fileSystem.path.join(directory.path, uriPath); final File file = fileSystem.file(filePath); if (!file.existsSync()) { return shelf.Response.notFound('Not Found'); } final String? contentType = resolver.lookup(file.path); final bool needsCrossOriginIsolated = crossOriginIsolated && uriPath.endsWith('.html'); return shelf.Response.ok( file.openRead(), headers: { 'Content-Type': ?contentType, if (needsCrossOriginIsolated) ...kCrossOriginIsolationHeaders, }, ); }; } class FlutterWebPlatform extends PlatformPlugin { FlutterWebPlatform._( this._server, this._config, this._root, { required this.updateGoldens, required this.buildInfo, required this.webMemoryFS, required FlutterProject flutterProject, required String flutterTesterBinPath, required FileSystem fileSystem, required Directory buildDirectory, required File testDartJs, required File testHostDartJs, required ChromiumLauncher chromiumLauncher, required Logger logger, required Artifacts? artifacts, required ProcessManager processManager, required this.webRenderer, required this.useWasm, required this.crossOriginIsolation, TestTimeRecorder? testTimeRecorder, }) : _fileSystem = fileSystem, _buildDirectory = buildDirectory, _testDartJs = testDartJs, _testHostDartJs = testHostDartJs, _chromiumLauncher = chromiumLauncher, _logger = logger, _artifacts = artifacts { final shelf.Cascade cascade = shelf.Cascade() .add(_webSocketHandler.handler) .add( createDirectoryHandler( fileSystem.directory( fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools'), ), crossOriginIsolated: crossOriginIsolation, ), ) .add(_handleStaticArtifact) .add(_localCanvasKitHandler) .add(_goldenFileHandler) .add(_wrapperHandler) .add(_handleTestRequest) .add( createDirectoryHandler( fileSystem.directory(fileSystem.path.join(fileSystem.currentDirectory.path, 'test')), crossOriginIsolated: crossOriginIsolation, ), ) .add(_packageFilesHandler); _server.mount(cascade.handler); _testGoldenComparator = TestGoldenComparator( compilerFactory: () => TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder), flutterTesterBinPath: flutterTesterBinPath, fileSystem: _fileSystem, logger: _logger, processManager: processManager, environment: { // Chrome is the only supported browser currently. 'FLUTTER_TEST_BROWSER': 'chrome', 'FLUTTER_WEB_RENDERER': webRenderer.name, }, ); } final WebMemoryFS webMemoryFS; final BuildInfo buildInfo; final FileSystem _fileSystem; final Directory _buildDirectory; final File _testDartJs; final File _testHostDartJs; final ChromiumLauncher _chromiumLauncher; final Logger _logger; final Artifacts? _artifacts; final bool updateGoldens; final _webSocketHandler = OneOffHandler(); final _closeMemo = AsyncMemoizer(); final String _root; final WebRendererMode webRenderer; final bool useWasm; final bool crossOriginIsolation; /// Allows only one test suite (typically one test file) to be loaded and run /// at any given point in time. Loading more than one file at a time is known /// to lead to flaky tests. final _suiteLock = Pool(1); BrowserManager? _browserManager; late TestGoldenComparator _testGoldenComparator; static Future defaultServerFactory() async { return shelf_io.IOServer(await HttpMultiServer.loopback(0)); } static Future start( String root, { bool updateGoldens = false, bool pauseAfterLoad = false, required FlutterProject flutterProject, required String flutterTesterBinPath, required BuildInfo buildInfo, required WebMemoryFS webMemoryFS, required FileSystem fileSystem, required Directory buildDirectory, required Logger logger, required ChromiumLauncher chromiumLauncher, required Artifacts? artifacts, required ProcessManager processManager, required WebRendererMode webRenderer, required bool useWasm, required bool crossOriginIsolation, TestTimeRecorder? testTimeRecorder, Uri? testPackageUri, Future Function() serverFactory = defaultServerFactory, }) async { final shelf.Server server = await serverFactory(); if (testPackageUri == null) { final PackageConfig packageConfig = await currentPackageConfig(); testPackageUri = packageConfig['test']!.packageUriRoot; } final File testDartJs = fileSystem.file( fileSystem.path.join(testPackageUri.toFilePath(), 'dart.js'), ); final File testHostDartJs = fileSystem.file( fileSystem.path.join( testPackageUri.toFilePath(), 'src', 'runner', 'browser', 'static', 'host.dart.js', ), ); return FlutterWebPlatform._( server, Configuration.current.change(pauseAfterLoad: pauseAfterLoad), root, flutterProject: flutterProject, flutterTesterBinPath: flutterTesterBinPath, updateGoldens: updateGoldens, buildInfo: buildInfo, webMemoryFS: webMemoryFS, testDartJs: testDartJs, testHostDartJs: testHostDartJs, fileSystem: fileSystem, buildDirectory: buildDirectory, chromiumLauncher: chromiumLauncher, artifacts: artifacts, logger: logger, processManager: processManager, webRenderer: webRenderer, useWasm: useWasm, crossOriginIsolation: crossOriginIsolation, testTimeRecorder: testTimeRecorder, ); } bool get _closed => _closeMemo.hasRun; final Configuration _config; final shelf.Server _server; Uri get url => _server.url; /// The ahem text file. File get _ahem => _fileSystem.file( _fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools', 'static', 'Ahem.ttf'), ); /// The require js binary. File get _requireJs => _fileSystem.file( _fileSystem.path.join( _artifacts!.getArtifactPath( Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript, ), 'lib', 'dev_compiler', 'amd', 'require.js', ), ); /// The ddc module loader js binary. File get _ddcModuleLoaderJs => _fileSystem.file( _fileSystem.path.join( _artifacts!.getArtifactPath( Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript, ), 'lib', 'dev_compiler', 'ddc', 'ddc_module_loader.js', ), ); /// The ddc to dart stack trace mapper. File get _stackTraceMapper => _fileSystem.file( _fileSystem.path.join( _artifacts!.getArtifactPath( Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript, ), 'lib', 'dev_compiler', 'web', 'dart_stack_trace_mapper.js', ), ); File get _flutterJs => _fileSystem.file( _fileSystem.path.join( _artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, 'flutter.js', ), ); File get _dartSdk { // TODO(srujzs): Remove this assertion when the library bundle format is // supported without canary mode. if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) { assert(buildInfo.canaryFeatures); } final Map dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc ? kDdcLibraryBundleDartSdkJsArtifactMap : kAmdDartSdkJsArtifactMap; return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!)); } File get _dartSdkSourcemaps { // TODO(srujzs): Remove this assertion when the library bundle format is // supported without canary mode. if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) { assert(buildInfo.canaryFeatures); } final Map dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc ? kDdcLibraryBundleDartSdkJsMapArtifactMap : kAmdDartSdkJsMapArtifactMap; return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!)); } File _canvasKitFile(String relativePath) { final String canvasKitPath = _fileSystem.path.join( _artifacts!.getHostArtifact(HostArtifact.flutterWebSdk).path, 'canvaskit', ); final File canvasKitFile = _fileSystem.file(_fileSystem.path.join(canvasKitPath, relativePath)); return canvasKitFile; } Future _handleTestRequest(shelf.Request request) async { if (request.url.path.endsWith('main.dart.browser_test.dart.js')) { return shelf.Response.ok( generateTestBootstrapFileContents( '/main.dart.bootstrap.js', 'require.js', 'dart_stack_trace_mapper.js', ), headers: {HttpHeaders.contentTypeHeader: 'text/javascript'}, ); } if (request.url.path.endsWith('main.dart.bootstrap.js')) { return shelf.Response.ok( generateMainModule( nativeNullAssertions: true, bootstrapModule: 'main.dart.bootstrap', entrypoint: '/main.dart.js', ), headers: {HttpHeaders.contentTypeHeader: 'text/javascript'}, ); } if (request.url.path.endsWith('.dart.js')) { final String path = request.url.path.split('.dart.js')[0]; return shelf.Response.ok( webMemoryFS.files['$path.dart.lib.js'], headers: {HttpHeaders.contentTypeHeader: 'text/javascript'}, ); } if (request.url.path.endsWith('.lib.js.map')) { return shelf.Response.ok( webMemoryFS.sourcemaps[request.url.path], headers: {HttpHeaders.contentTypeHeader: 'text/plain'}, ); } return shelf.Response.notFound(''); } Future _handleStaticArtifact(shelf.Request request) async { if (request.requestedUri.path.contains('require.js')) { return shelf.Response.ok( _requireJs.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('ddc_module_loader.js')) { return shelf.Response.ok( _ddcModuleLoaderJs.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('ahem.ttf')) { return shelf.Response.ok(_ahem.openRead()); } else if (request.requestedUri.path.contains('dart_sdk.js')) { return shelf.Response.ok( _dartSdk.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('dart_sdk.js.map')) { return shelf.Response.ok( _dartSdkSourcemaps.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('dart_stack_trace_mapper.js')) { return shelf.Response.ok( _stackTraceMapper.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('static/dart.js')) { return shelf.Response.ok( _testDartJs.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('host.dart.js')) { return shelf.Response.ok( _testHostDartJs.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('flutter.js')) { return shelf.Response.ok( _flutterJs.openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('main.dart.mjs')) { return shelf.Response.ok( _buildDirectory.childFile('main.dart.mjs').openRead(), headers: {'Content-Type': 'text/javascript'}, ); } else if (request.requestedUri.path.contains('main.dart.wasm')) { return shelf.Response.ok( _buildDirectory.childFile('main.dart.wasm').openRead(), headers: {'Content-Type': 'application/wasm'}, ); } else { return shelf.Response.notFound('Not Found'); } } FutureOr _packageFilesHandler(shelf.Request request) async { if (request.requestedUri.pathSegments.first == 'packages') { final Uri? fileUri = buildInfo.packageConfig.resolve( Uri(scheme: 'package', pathSegments: request.requestedUri.pathSegments.skip(1)), ); if (fileUri != null) { final String dirname = _fileSystem.path.dirname(fileUri.toFilePath()); final String basename = _fileSystem.path.basename(fileUri.toFilePath()); final shelf.Handler handler = createDirectoryHandler( _fileSystem.directory(dirname), crossOriginIsolated: crossOriginIsolation, ); final modifiedRequest = shelf.Request( request.method, request.requestedUri.replace(path: basename), protocolVersion: request.protocolVersion, headers: request.headers, handlerPath: request.handlerPath, url: request.url.replace(path: basename), encoding: request.encoding, context: request.context, ); return handler(modifiedRequest); } } return shelf.Response.notFound('Not Found'); } Future _goldenFileHandler(shelf.Request request) async { if (request.url.path.contains('flutter_goldens')) { final body = json.decode(await request.readAsString()) as Map; final Uri goldenKey = Uri.parse(body['key']! as String); final Uri testUri = Uri.parse(body['testUri']! as String); Uint8List bytes; if (body.containsKey('bytes')) { bytes = base64.decode(body['bytes']! as String); } else { return shelf.Response.ok('Request must contain bytes in the body.'); } if (updateGoldens) { return switch (await _testGoldenComparator.update(testUri, bytes, goldenKey)) { TestGoldenUpdateDone() => shelf.Response.ok('true'), TestGoldenUpdateError(error: final String error) => shelf.Response.ok(error), }; } else { return switch (await _testGoldenComparator.compare(testUri, bytes, goldenKey)) { TestGoldenComparisonDone(matched: final bool matched) => shelf.Response.ok('$matched'), TestGoldenComparisonError(error: final String error) => shelf.Response.ok(error), }; } } else { return shelf.Response.notFound('Not Found'); } } /// Serves a local build of CanvasKit, replacing the CDN build, which can /// cause test flakiness due to reliance on network. shelf.Response _localCanvasKitHandler(shelf.Request request) { final String fullPath = _fileSystem.path.fromUri(request.url); if (!fullPath.startsWith('canvaskit/')) { return shelf.Response.notFound('Not a CanvasKit file request'); } final String relativePath = fullPath.replaceFirst('canvaskit/', ''); final String extension = _fileSystem.path.extension(relativePath); String contentType; switch (extension) { case '.js': contentType = 'text/javascript'; case '.wasm': contentType = 'application/wasm'; default: final error = 'Failed to determine Content-Type for "${request.url.path}".'; _logger.printError(error); return shelf.Response.internalServerError(body: error); } final File canvasKitFile = _canvasKitFile(relativePath); return shelf.Response.ok( canvasKitFile.openRead(), headers: {HttpHeaders.contentTypeHeader: contentType}, ); } String _makeBuildConfigString() { return useWasm ? ''' { compileTarget: "dart2wasm", renderer: "${webRenderer.name}", mainWasmPath: "main.dart.wasm", jsSupportRuntimePath: "main.dart.mjs", } ''' : ''' { compileTarget: "dartdevc", renderer: "${webRenderer.name}", mainJsPath: "main.dart.browser_test.dart.js", } '''; } // A handler that serves wrapper files used to bootstrap tests. shelf.Response _wrapperHandler(shelf.Request request) { final String path = _fileSystem.path.fromUri(request.url); if (path.endsWith('.html')) { final test = '${_fileSystem.path.withoutExtension(path)}.dart'; return shelf.Response.ok( ''' ${htmlEscape.convert(test)} Test ''', headers: { 'Content-Type': 'text/html', if (webRenderer == WebRendererMode.skwasm) ...kCrossOriginIsolationHeaders, }, ); } return shelf.Response.notFound('Not found.'); } @override Future load( String path, SuitePlatform platform, SuiteConfiguration suiteConfig, Object message, ) async { if (_closed) { throw StateError('Load called on a closed FlutterWebPlatform'); } final String pathFromTest = _fileSystem.path.relative( path, from: _fileSystem.path.join(_root, 'test'), ); final Uri suiteUrl = url.resolveUri( _fileSystem.path.toUri('${_fileSystem.path.withoutExtension(pathFromTest)}.html'), ); final String relativePath = _fileSystem.path.relative( _fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path, ); if (_logger.isVerbose) { _logger.printTrace('Loading test suite $relativePath.'); } final PoolResource lockResource = await _suiteLock.request(); final Runtime browser = platform.runtime; try { _browserManager ??= await _launchBrowser(browser); } on Error catch (_) { await _suiteLock.close(); rethrow; } if (_closed) { throw StateError('Load called on a closed FlutterWebPlatform'); } if (_logger.isVerbose) { _logger.printTrace('Running test suite $relativePath.'); } final RunnerSuite suite = await _browserManager!.load( relativePath, suiteUrl, suiteConfig, message, onDone: () async { lockResource.release(); if (_logger.isVerbose) { _logger.printTrace('Test suite $relativePath finished.'); } }, ); if (_closed) { throw StateError('Load called on a closed FlutterWebPlatform'); } return suite; } /// Returns the [BrowserManager] for [browser], which should be a browser. /// /// If no browser manager is running yet, starts one. Future _launchBrowser(Runtime browser) { if (_browserManager != null) { throw StateError('Another browser is currently running.'); } final completer = Completer.sync(); final String path = _webSocketHandler.create( webSocketHandler((WebSocketChannel webSocket, _) { completer.complete(webSocket); }), ); final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path); final Uri hostUrl = url .resolve('static/index.html') .replace( queryParameters: { 'managerUrl': webSocketUrl.toString(), 'debug': _config.pauseAfterLoad.toString(), }, ); _logger.printTrace('Serving tests at $hostUrl'); return BrowserManager.start( _chromiumLauncher, browser, hostUrl, completer.future, headless: !_config.pauseAfterLoad, ); } @override Future closeEphemeral() async { if (_browserManager != null) { await _browserManager!.close(); } } @override Future close() => _closeMemo.runOnce(() async { await Future.wait(>[ ?_browserManager?.close(), _server.close(), _testGoldenComparator.close(), ]); }); } class OneOffHandler { /// A map from URL paths to handlers. final _handlers = {}; /// The counter of handlers that have been activated. var _counter = 0; /// The actual [shelf.Handler] that dispatches requests. shelf.Handler get handler => _onRequest; /// Creates a new one-off handler that forwards to [handler]. /// /// Returns a string that's the URL path for hitting this handler, relative to /// the URL for the one-off handler itself. /// /// [handler] will be unmounted as soon as it receives a request. String create(shelf.Handler handler) { final path = _counter.toString(); _handlers[path] = handler; _counter++; return path; } /// Dispatches [request] to the appropriate handler. FutureOr _onRequest(shelf.Request request) { final List components = request.url.path.split('/'); if (components.isEmpty) { return shelf.Response.notFound(null); } final String path = components.removeAt(0); final FutureOr Function(shelf.Request)? handler = _handlers.remove(path); if (handler == null) { return shelf.Response.notFound(null); } return handler(request.change(path: path)); } } class BrowserManager { /// Creates a new BrowserManager that communicates with [_browser] over /// [webSocket]. BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) { // The duration should be short enough that the debugging console is open as // soon as the user is done setting breakpoints, but long enough that a test // doing a lot of synchronous work doesn't trigger a false positive. // // Start this canceled because we don't want it to start ticking until we // get some response from the iframe. _timer = RestartableTimer(const Duration(seconds: 3), () { for (final RunnerSuiteController controller in _controllers) { controller.setDebugging(true); } })..cancel(); // Whenever we get a message, no matter which child channel it's for, we know // the browser is still running code which means the user isn't debugging. _channel = MultiChannel( webSocket.cast().transform(jsonDocument).changeStream((Stream stream) { return stream.map((Object? message) { if (!_closed) { _timer.reset(); } for (final RunnerSuiteController controller in _controllers) { controller.setDebugging(false); } return message; }); }), ); _environment = _loadBrowserEnvironment(); _channel.stream.listen(_onMessage, onDone: close); } /// The browser instance that this is connected to via [_channel]. final Chromium _browser; final Runtime _runtime; /// The channel used to communicate with the browser. /// /// This is connected to a page running `static/host.dart`. late MultiChannel _channel; /// The ID of the next suite to be loaded. /// /// This is used to ensure that the suites can be referred to consistently /// across the client and server. var _suiteID = 0; /// Whether the channel to the browser has closed. var _closed = false; /// The completer for [_BrowserEnvironment.displayPause]. /// /// This will be `null` as long as the browser isn't displaying a pause /// screen. CancelableCompleter? _pauseCompleter; /// The controller for [_BrowserEnvironment.onRestart]. final _onRestartController = StreamController.broadcast(); /// The environment to attach to each suite. late Future<_BrowserEnvironment> _environment; /// Controllers for every suite in this browser. /// /// These are used to mark suites as debugging or not based on the browser's /// pings. final _controllers = {}; // A timer that's reset whenever we receive a message from the browser. // // Because the browser stops running code when the user is actively debugging, // this lets us detect whether they're debugging reasonably accurately. late RestartableTimer _timer; final _closeMemoizer = AsyncMemoizer(); /// Starts the browser identified by [runtime] and has it connect to [url]. /// /// [url] should serve a page that establishes a WebSocket connection with /// this process. That connection, once established, should be emitted via /// [future]. If [debug] is true, starts the browser in debug mode, with its /// debugger interfaces on and detected. /// /// The browser will start in headless mode if [headless] is `true`. /// /// Add arbitrary browser flags via [webBrowserFlags]. /// /// Returns the browser manager, or throws an exception if /// a connection fails to be established. static Future start( ChromiumLauncher chromiumLauncher, Runtime runtime, Uri url, Future future, { bool debug = false, bool headless = true, List webBrowserFlags = const [], }) async { final Chromium chrome = await chromiumLauncher.launch( url.toString(), headless: headless, webBrowserFlags: webBrowserFlags, ); final completer = Completer(); unawaited( chrome.onExit .then((int? browserExitCode) { throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.'); }) .then( (Object? obj) => obj, onError: (Object error, StackTrace stackTrace) { if (!completer.isCompleted) { completer.completeError(error, stackTrace); } return null; }, ), ); unawaited( future.then( (WebSocketChannel webSocket) { if (completer.isCompleted) { return; } completer.complete(BrowserManager._(chrome, runtime, webSocket)); }, onError: (Object error, StackTrace stackTrace) { chrome.close(); if (!completer.isCompleted) { completer.completeError(error, stackTrace); } }, ), ); return completer.future; } /// Loads [_BrowserEnvironment]. Future<_BrowserEnvironment> _loadBrowserEnvironment() async { return _BrowserEnvironment( this, null, _browser.chromeConnection.url, _onRestartController.stream, ); } /// Tells the browser to load a test suite from the URL [url]. /// /// [url] should be an HTML page with a reference to the JS-compiled test /// suite. [path] is the path of the original test suite file, which is used /// for reporting. [suiteConfig] is the configuration for the test suite. Future load( String path, Uri url, SuiteConfiguration suiteConfig, Object message, { Future Function()? onDone, }) async { url = url.replace( fragment: Uri.encodeFull( jsonEncode({ 'metadata': suiteConfig.metadata.serialize(), 'browser': _runtime.identifier, }), ), ); final int suiteID = _suiteID++; RunnerSuiteController? controller; void closeIframe() { if (_closed) { return; } _controllers.remove(controller); _channel.sink.add({'command': 'closeSuite', 'id': suiteID}); } // The virtual channel will be closed when the suite is closed, in which // case we should unload the iframe. final VirtualChannel virtualChannel = _channel.virtualChannel(); final int suiteChannelID = virtualChannel.id; final StreamChannel suiteChannel = virtualChannel.transformStream( StreamTransformer.fromHandlers( handleDone: (EventSink sink) { closeIframe(); sink.close(); onDone!(); }, ), ); _channel.sink.add({ 'command': 'loadSuite', 'url': url.toString(), 'id': suiteID, 'channel': suiteChannelID, }); try { controller = deserializeSuite( path, SuitePlatform(Runtime.chrome), suiteConfig, await _environment, suiteChannel, message, ); _controllers.add(controller); return await controller.suite; // Not limiting to catching Exception because the exception is rethrown. } catch (_) { closeIframe(); rethrow; } } /// An implementation of [Environment.displayPause]. CancelableOperation _displayPause() { if (_pauseCompleter != null) { return _pauseCompleter!.operation; } _pauseCompleter = CancelableCompleter( onCancel: () { _channel.sink.add({'command': 'resume'}); _pauseCompleter = null; }, ); _pauseCompleter!.operation.value.whenComplete(() { _pauseCompleter = null; }); _channel.sink.add({'command': 'displayPause'}); return _pauseCompleter!.operation; } /// The callback for handling messages received from the host page. void _onMessage(dynamic message) { assert(message is Map); if (message is Map) { switch (message['command'] as String?) { case 'ping': break; case 'restart': _onRestartController.add(null); case 'resume': if (_pauseCompleter != null) { _pauseCompleter!.complete(); } default: // Unreachable. assert(false); } } } /// Closes the manager and releases any resources it owns, including closing /// the browser. Future close() { return _closeMemoizer.runOnce(() { _closed = true; _timer.cancel(); if (_pauseCompleter != null) { _pauseCompleter!.complete(); } _pauseCompleter = null; _controllers.clear(); return _browser.close(); }); } } /// An implementation of [Environment] for the browser. /// /// All methods forward directly to [BrowserManager]. class _BrowserEnvironment implements Environment { _BrowserEnvironment(this._manager, this.observatoryUrl, this.remoteDebuggerUrl, this.onRestart); final BrowserManager _manager; @override final supportsDebugging = true; // TODO(bkonyi): update package:test_core to no longer reference Observatory. @override final Uri? observatoryUrl; @override final Uri remoteDebuggerUrl; @override final Stream onRestart; @override CancelableOperation displayPause() => _manager._displayPause(); }