| // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file | |
| // for details. 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:convert'; | |
| import 'package:async/async.dart'; | |
| import 'package:pool/pool.dart'; | |
| import 'package:stream_channel/stream_channel.dart'; | |
| import 'package:test_api/backend.dart' show Compiler, Runtime, StackTraceMapper; | |
| import 'package:test_core/src/runner/application_exception.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/environment.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/load_exception.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/plugin/platform_helpers.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports | |
| import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports | |
| import 'package:web_socket_channel/web_socket_channel.dart'; | |
| import '../executable_settings.dart'; | |
| import 'browser.dart'; | |
| import 'chrome.dart'; | |
| import 'firefox.dart'; | |
| import 'microsoft_edge.dart'; | |
| import 'safari.dart'; | |
| /// A class that manages the connection to a single running browser. | |
| /// | |
| /// This is in charge of telling the browser which test suites to load and | |
| /// converting its responses into [Suite] objects. | |
| class BrowserManager { | |
| /// The browser instance that this is connected to via [_channel]. | |
| final Browser _browser; | |
| // TODO(nweiz): Consider removing the duplication between this and | |
| // [_browser.name]. | |
| /// The [Runtime] for [_browser]. | |
| final Runtime _runtime; | |
| /// The channel used to communicate with the browser. | |
| /// | |
| /// This is connected to a page running `static/host.dart`. | |
| late final MultiChannel<Object> _channel; | |
| /// A pool that ensures that limits the number of initial connections the | |
| /// manager will wait for at once. | |
| /// | |
| /// This isn't the *total* number of connections; any number of iframes may be | |
| /// loaded in the same browser. However, the browser can only load so many at | |
| /// once, and we want a timeout in case they fail so we only wait for so many | |
| /// at once. | |
| final _pool = Pool(8); | |
| /// 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. | |
| int _suiteID = 0; | |
| /// Whether the channel to the browser has closed. | |
| bool _closed = false; | |
| /// The completer for [_BrowserEnvironment.displayPause]. | |
| /// | |
| /// This will be `null` as long as the browser isn't displaying a pause | |
| /// screen. | |
| CancelableCompleter<void>? _pauseCompleter; | |
| /// The controller for [_BrowserEnvironment.onRestart]. | |
| final _onRestartController = StreamController<void>.broadcast(); | |
| /// The environment to attach to each suite. | |
| late final 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 = <RunnerSuiteController>{}; | |
| // 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 final RestartableTimer _timer; | |
| /// 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 [settings] indicate how to invoke this browser's executable. | |
| /// | |
| /// Returns the browser manager, or throws an [ApplicationException] if a | |
| /// connection fails to be established. | |
| static Future<BrowserManager> start( | |
| Runtime runtime, | |
| Uri url, | |
| Future<WebSocketChannel> future, | |
| ExecutableSettings settings, | |
| Configuration configuration, | |
| ) => _start(runtime, url, future, settings, configuration, 1); | |
| static const _maxRetries = 3; | |
| static Future<BrowserManager> _start( | |
| Runtime runtime, | |
| Uri url, | |
| Future<WebSocketChannel> future, | |
| ExecutableSettings settings, | |
| Configuration configuration, | |
| int attempt, | |
| ) { | |
| var browser = _newBrowser(url, runtime, settings, configuration); | |
| var completer = Completer<BrowserManager>(); | |
| // TODO(nweiz): Gracefully handle the browser being killed before the | |
| // tests complete. | |
| browser.onExit | |
| .then<void>( | |
| (_) => throw ApplicationException( | |
| '${runtime.name} exited before connecting.', | |
| ), | |
| ) | |
| .onError<Object>((error, stackTrace) { | |
| if (!completer.isCompleted) { | |
| completer.completeError(error, stackTrace); | |
| } | |
| }); | |
| future | |
| .then((webSocket) { | |
| if (completer.isCompleted) return; | |
| completer.complete(BrowserManager._(browser, runtime, webSocket)); | |
| }) | |
| .onError((Object error, StackTrace stackTrace) { | |
| browser.close(); | |
| if (completer.isCompleted) return; | |
| completer.completeError(error, stackTrace); | |
| }); | |
| return completer.future.timeout( | |
| const Duration(seconds: 30), | |
| onTimeout: () { | |
| browser.close(); | |
| if (attempt >= _maxRetries) { | |
| throw ApplicationException( | |
| 'Timed out waiting for ${runtime.name} to connect.\n' | |
| 'Browser output: ${browser.output.join('\n')}', | |
| ); | |
| } | |
| return _start(runtime, url, future, settings, configuration, ++attempt); | |
| }, | |
| ); | |
| } | |
| /// Starts the browser identified by [browser] using [settings] and has it load [url]. | |
| /// | |
| /// If [debug] is true, starts the browser in debug mode. | |
| static Browser _newBrowser( | |
| Uri url, | |
| Runtime browser, | |
| ExecutableSettings settings, | |
| Configuration configuration, | |
| ) => switch (browser.root) { | |
| Runtime.chrome => Chrome(url, configuration, settings: settings), | |
| Runtime.firefox => Firefox(url, settings: settings), | |
| Runtime.safari => Safari(url, settings: settings), | |
| Runtime.edge => MicrosoftEdge(url, configuration, settings: settings), | |
| _ => throw ArgumentError('$browser is not a browser.'), | |
| }; | |
| /// 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 (var controller in _controllers) { | |
| controller.setDebugging(true); | |
| } | |
| })..cancel(); | |
| // Whenever we get a message, no matter which child channel it's for, we the | |
| // know browser is still running code which means the user isn't debugging. | |
| _channel = MultiChannel( | |
| webSocket.cast<String>().transform(jsonDocument).changeStream((stream) { | |
| return stream.map((message) { | |
| if (!_closed) _timer.reset(); | |
| for (var controller in _controllers) { | |
| controller.setDebugging(false); | |
| } | |
| return message; | |
| }); | |
| }), | |
| ); | |
| _environment = _loadBrowserEnvironment(); | |
| _channel.stream.listen( | |
| (message) => _onMessage(message as Map<Object, Object?>), | |
| onDone: close, | |
| ); | |
| } | |
| /// Loads [_BrowserEnvironment]. | |
| Future<_BrowserEnvironment> _loadBrowserEnvironment() async => | |
| _BrowserEnvironment( | |
| this, | |
| await _browser.remoteDebuggerUrl, | |
| _onRestartController.stream, | |
| ); | |
| /// Tells the browser the 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. | |
| /// | |
| /// If [mapper] is passed, it's used to map stack traces for errors coming | |
| /// from this test suite. | |
| Future<RunnerSuite> load( | |
| String path, | |
| Uri url, | |
| SuiteConfiguration suiteConfig, | |
| Map<String, Object?> message, | |
| Compiler compiler, { | |
| StackTraceMapper? mapper, | |
| Duration? timeout, | |
| }) async { | |
| url = url.replace( | |
| fragment: Uri.encodeFull( | |
| jsonEncode({ | |
| 'metadata': suiteConfig.metadata.serialize(), | |
| 'browser': _runtime.identifier, | |
| 'compiler': compiler.serialize(), | |
| }), | |
| ), | |
| ); | |
| var suiteID = _suiteID++; | |
| RunnerSuiteController? controller; | |
| void closeIframe() { | |
| if (_closed) return; | |
| if (controller != null) _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. | |
| var virtualChannel = _channel.virtualChannel(); | |
| var suiteChannelID = virtualChannel.id; | |
| var suiteChannel = virtualChannel.transformStream( | |
| StreamTransformer.fromHandlers( | |
| handleDone: (sink) { | |
| closeIframe(); | |
| sink.close(); | |
| }, | |
| ), | |
| ); | |
| var suite = _pool.withResource<RunnerSuite>(() async { | |
| _channel.sink.add({ | |
| 'command': 'loadSuite', | |
| 'url': url.toString(), | |
| 'id': suiteID, | |
| 'channel': suiteChannelID, | |
| }); | |
| try { | |
| controller = deserializeSuite( | |
| path, | |
| currentPlatform(_runtime, compiler), | |
| suiteConfig, | |
| await _environment, | |
| suiteChannel.cast(), | |
| message, | |
| gatherCoverage: () async { | |
| var browser = _browser; | |
| if (browser is Chrome) return browser.gatherCoverage(); | |
| return {}; | |
| }, | |
| ); | |
| controller! | |
| .channel('test.browser.mapper') | |
| .sink | |
| .add(mapper?.serialize()); | |
| _controllers.add(controller!); | |
| return await controller!.suite; | |
| } catch (_) { | |
| closeIframe(); | |
| rethrow; | |
| } | |
| }); | |
| if (timeout != null) { | |
| suite = suite.timeout( | |
| timeout, | |
| onTimeout: () { | |
| throw LoadException( | |
| path, | |
| 'Timed out waiting for browser to load test suite. ' | |
| 'Browser output: ${_browser.output.join('\n')}', | |
| ); | |
| }, | |
| ); | |
| } | |
| return suite; | |
| } | |
| /// An implementation of [Environment.displayPause]. | |
| CancelableOperation<void> _displayPause() { | |
| if (_pauseCompleter != null) return _pauseCompleter!.operation; | |
| final pauseCompleter = _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(Map<Object, Object?> message) { | |
| switch (message['command'] as String) { | |
| case 'ping': | |
| break; | |
| case 'restart': | |
| _onRestartController.add(null); | |
| break; | |
| case 'resume': | |
| _pauseCompleter?.complete(); | |
| break; | |
| default: | |
| // Unreachable. | |
| assert(false); | |
| break; | |
| } | |
| } | |
| /// Closes the manager and releases any resources it owns, including closing | |
| /// the browser. | |
| Future<void> close() => _closeMemoizer.runOnce(() { | |
| _closed = true; | |
| _timer.cancel(); | |
| _pauseCompleter?.complete(); | |
| _pauseCompleter = null; | |
| _controllers.clear(); | |
| return _browser.close(); | |
| }); | |
| final _closeMemoizer = AsyncMemoizer<void>(); | |
| } | |
| /// An implementation of [Environment] for the browser. | |
| /// | |
| /// All methods forward directly to [BrowserManager]. | |
| class _BrowserEnvironment implements Environment { | |
| final BrowserManager _manager; | |
| @override | |
| final supportsDebugging = true; | |
| @override | |
| Null get observatoryUrl => null; | |
| @override | |
| final Uri? remoteDebuggerUrl; | |
| @override | |
| final Stream<void> onRestart; | |
| _BrowserEnvironment(this._manager, this.remoteDebuggerUrl, this.onRestart); | |
| @override | |
| CancelableOperation<void> displayPause() => _manager._displayPause(); | |
| } |