blob: c77231db80da1dfac0c098dfec11650d77dc109d [file] [log] [blame]
// 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 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;
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 'flutter_web_goldens.dart';
import 'test_compiler.dart';
import 'test_time_recorder.dart';
shelf.Handler createDirectoryHandler(Directory directory, { required bool crossOriginIsolated} ) {
final mime.MimeTypeResolver 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: <String, String>{
if (contentType != null) 'Content-Type': contentType,
if (needsCrossOriginIsolated)
...<String, String>{
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
);
};
}
class FlutterWebPlatform extends PlatformPlugin {
FlutterWebPlatform._(this._server, this._config, this._root, {
FlutterProject? flutterProject,
String? shellPath,
this.updateGoldens,
this.nullAssertions,
required this.buildInfo,
required this.webMemoryFS,
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,
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: webRenderer == WebRendererMode.skwasm,
))
.add(_handleStaticArtifact)
.add(_localCanvasKitHandler)
.add(_goldenFileHandler)
.add(_wrapperHandler)
.add(_handleTestRequest)
.add(createDirectoryHandler(
fileSystem.directory(fileSystem.path.join(fileSystem.currentDirectory.path, 'test')),
crossOriginIsolated: webRenderer == WebRendererMode.skwasm,
))
.add(_packageFilesHandler);
_server.mount(cascade.handler);
_testGoldenComparator = TestGoldenComparator(
shellPath,
() => TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
fileSystem: _fileSystem,
logger: _logger,
processManager: processManager,
webRenderer: webRenderer,
);
}
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 bool? nullAssertions;
final OneOffHandler _webSocketHandler = OneOffHandler();
final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
final String _root;
final WebRendererMode webRenderer;
final bool useWasm;
/// 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 Pool _suiteLock = Pool(1);
BrowserManager? _browserManager;
late TestGoldenComparator _testGoldenComparator;
static Future<shelf.Server> defaultServerFactory() async {
return shelf_io.IOServer(await HttpMultiServer.loopback(0));
}
static Future<FlutterWebPlatform> start(String root, {
FlutterProject? flutterProject,
String? shellPath,
bool updateGoldens = false,
bool pauseAfterLoad = false,
bool nullAssertions = false,
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,
TestTimeRecorder? testTimeRecorder,
Uri? testPackageUri,
Future<shelf.Server> Function() serverFactory = defaultServerFactory,
}) async {
final shelf.Server server = await serverFactory();
if (testPackageUri == null) {
final PackageConfig packageConfig = await loadPackageConfigWithLogging(
fileSystem.file(fileSystem.path.join(
Cache.flutterRoot!,
'packages',
'flutter_tools',
'.dart_tool',
'package_config.json',
)),
logger: logger,
);
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,
shellPath: shellPath,
updateGoldens: updateGoldens,
buildInfo: buildInfo,
webMemoryFS: webMemoryFS,
testDartJs: testDartJs,
testHostDartJs: testHostDartJs,
fileSystem: fileSystem,
buildDirectory: buildDirectory,
chromiumLauncher: chromiumLauncher,
artifacts: artifacts,
logger: logger,
nullAssertions: nullAssertions,
processManager: processManager,
webRenderer: webRenderer,
useWasm: useWasm,
testTimeRecorder: testTimeRecorder,
);
}
bool get _closed => _closeMemo.hasRun;
NullSafetyMode get _nullSafetyMode {
return buildInfo.nullSafetyMode == NullSafetyMode.sound
? NullSafetyMode.sound
: NullSafetyMode.unsound;
}
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 {
final Map<WebRendererMode, Map<NullSafetyMode, HostArtifact>> dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc ? kDdcDartSdkJsArtifactMap : kAmdDartSdkJsArtifactMap;
return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]![_nullSafetyMode]!));
}
File get _dartSdkSourcemaps {
final Map<WebRendererMode, Map<NullSafetyMode, HostArtifact>> dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc ? kDdcDartSdkJsMapArtifactMap : kAmdDartSdkJsMapArtifactMap;
return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]![_nullSafetyMode]!));
}
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<shelf.Response> _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: <String, String>{
HttpHeaders.contentTypeHeader: 'text/javascript',
}
);
}
if (request.url.path.endsWith('main.dart.bootstrap.js')) {
return shelf.Response.ok(generateMainModule(
nullAssertions: nullAssertions!,
nativeNullAssertions: true,
bootstrapModule: 'main.dart.bootstrap',
entrypoint: '/main.dart.js'
), headers: <String, String>{
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: <String, String>{
HttpHeaders.contentTypeHeader: 'text/javascript',
});
}
if (request.url.path.endsWith('.lib.js.map')) {
return shelf.Response.ok(webMemoryFS.sourcemaps[request.url.path], headers: <String, String>{
HttpHeaders.contentTypeHeader: 'text/plain',
});
}
return shelf.Response.notFound('');
}
Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
if (request.requestedUri.path.contains('require.js')) {
return shelf.Response.ok(
_requireJs.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('ddc_module_loader.js')) {
return shelf.Response.ok(
_ddcModuleLoaderJs.openRead(),
headers: <String, String>{'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: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('dart_sdk.js.map')) {
return shelf.Response.ok(
_dartSdkSourcemaps.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path
.contains('dart_stack_trace_mapper.js')) {
return shelf.Response.ok(
_stackTraceMapper.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('static/dart.js')) {
return shelf.Response.ok(
_testDartJs.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('host.dart.js')) {
return shelf.Response.ok(
_testHostDartJs.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('flutter.js')) {
return shelf.Response.ok(
_flutterJs.openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('main.dart.mjs')) {
return shelf.Response.ok(
_buildDirectory.childFile('main.dart.mjs').openRead(),
headers: <String, String>{'Content-Type': 'text/javascript'},
);
} else if (request.requestedUri.path.contains('main.dart.wasm')) {
return shelf.Response.ok(
_buildDirectory.childFile('main.dart.wasm').openRead(),
headers: <String, String>{'Content-Type': 'application/wasm'},
);
} else {
return shelf.Response.notFound('Not Found');
}
}
FutureOr<shelf.Response> _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: webRenderer == WebRendererMode.skwasm,
);
final shelf.Request 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<shelf.Response> _goldenFileHandler(shelf.Request request) async {
if (request.url.path.contains('flutter_goldens')) {
final Map<String, Object?> body = json.decode(await request.readAsString()) as Map<String, Object?>;
final Uri goldenKey = Uri.parse(body['key']! as String);
final Uri testUri = Uri.parse(body['testUri']! as String);
final num? width = body['width'] as num?;
final num? height = body['height'] as num?;
Uint8List bytes;
if (body.containsKey('bytes')) {
bytes = base64.decode(body['bytes']! as String);
} else {
// TODO(hterkelsen): Do not use browser screenshots for testing on the
// web once we transition off the HTML renderer. See:
// https://github.com/flutter/flutter/issues/135700
try {
final ChromeTab chromeTab = (await _browserManager!._browser.chromeConnection.getTab((ChromeTab tab) {
return tab.url.contains(_browserManager!._browser.url!);
}))!;
final WipConnection connection = await chromeTab.connect();
final WipResponse response = await connection.sendCommand('Page.captureScreenshot', <String, Object>{
// Clip the screenshot to include only the element.
// Prior to taking a screenshot, we are calling `window.render()` in
// `_matchers_web.dart` to only render the element on screen. That
// will make sure that the element will always be displayed on the
// origin of the screen.
'clip': <String, Object>{
'x': 0.0,
'y': 0.0,
'width': width!.toDouble(),
'height': height!.toDouble(),
'scale': 1.0,
},
});
bytes = base64.decode(response.result!['data'] as String);
} on WipError catch (ex) {
_logger.printError('Caught WIPError: $ex');
return shelf.Response.ok('WIP error: $ex');
} on FormatException catch (ex) {
_logger.printError('Caught FormatException: $ex');
return shelf.Response.ok('Caught exception: $ex');
}
}
final String? errorMessage = await _testGoldenComparator.compareGoldens(testUri, bytes, goldenKey, updateGoldens);
return shelf.Response.ok(errorMessage ?? 'true');
} 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 String 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: <String, Object>{
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 String test = '${_fileSystem.path.withoutExtension(path)}.dart';
return shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${htmlEscape.convert(test)} Test</title>
<script src="flutter.js"></script>
<script>
_flutter.buildConfig = {
builds: [
${_makeBuildConfigString()}
]
}
window.testSelector = "$test";
_flutter.loader.load({
config: {
canvasKitBaseUrl: "/canvaskit/",
}
});
</script>
</head>
</html>
''', headers: <String, String>{
'Content-Type': 'text/html',
if (webRenderer == WebRendererMode.skwasm)
...<String, String>{
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}
});
}
return shelf.Response.notFound('Not found.');
}
@override
Future<RunnerSuite> 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 {
await _browserManager!.close();
_browserManager = null;
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 [runtime], which should be a browser.
///
/// If no browser manager is running yet, starts one.
Future<BrowserManager> _launchBrowser(Runtime browser) {
if (_browserManager != null) {
throw StateError('Another browser is currently running.');
}
final Completer<WebSocketChannel> completer = Completer<WebSocketChannel>.sync();
final String path = _webSocketHandler.create(webSocketHandler(completer.complete));
final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
final Uri hostUrl = url
.resolve('static/index.html')
.replace(queryParameters: <String, String>{
'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<void> closeEphemeral() async {
if (_browserManager != null) {
await _browserManager!.close();
}
}
@override
Future<void> close() => _closeMemo.runOnce(() async {
await Future.wait<void>(<Future<dynamic>>[
if (_browserManager != null)
_browserManager!.close(),
_server.close(),
_testGoldenComparator.close(),
]);
});
}
class OneOffHandler {
/// A map from URL paths to handlers.
final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{};
/// The counter of handlers that have been activated.
int _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 String path = _counter.toString();
_handlers[path] = handler;
_counter++;
return path;
}
/// Dispatches [request] to the appropriate handler.
FutureOr<shelf.Response> _onRequest(shelf.Request request) {
final List<String> components = request.url.path.split('/');
if (components.isEmpty) {
return shelf.Response.notFound(null);
}
final String path = components.removeAt(0);
final FutureOr<shelf.Response> 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<dynamic>(
webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object?> 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<dynamic> _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.
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<dynamic>? _pauseCompleter;
/// The controller for [_BrowserEnvironment.onRestart].
final StreamController<dynamic> _onRestartController =
StreamController<dynamic>.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 Set<RunnerSuiteController> _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 RestartableTimer _timer;
final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>();
/// 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].
///
/// 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(
ChromiumLauncher chromiumLauncher,
Runtime runtime,
Uri url,
Future<WebSocketChannel> future, {
bool debug = false,
bool headless = true,
List<String> webBrowserFlags = const <String>[],
}) async {
final Chromium chrome = await chromiumLauncher.launch(
url.toString(),
headless: headless,
webBrowserFlags: webBrowserFlags,
);
final Completer<BrowserManager> completer = Completer<BrowserManager>();
unawaited(chrome.onExit.then<Object?>(
(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.
///
/// 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,
Object message, {
Future<void> Function()? onDone,
}
) async {
url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
'metadata': suiteConfig.metadata.serialize(),
'browser': _runtime.identifier,
})));
final int suiteID = _suiteID++;
RunnerSuiteController? controller;
void closeIframe() {
if (_closed) {
return;
}
_controllers.remove(controller);
_channel.sink
.add(<String, Object>{'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<dynamic> virtualChannel = _channel.virtualChannel();
final int suiteChannelID = virtualChannel.id;
final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
closeIframe();
sink.close();
onDone!();
}),
);
_channel.sink.add(<String, Object>{
'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 (_) { // ignore: avoid_catches_without_on_clauses
closeIframe();
rethrow;
}
}
/// An implementation of [Environment.displayPause].
CancelableOperation<dynamic> _displayPause() {
if (_pauseCompleter != null) {
return _pauseCompleter!.operation;
}
_pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
_channel.sink.add(<String, String>{'command': 'resume'});
_pauseCompleter = null;
});
_pauseCompleter!.operation.value.whenComplete(() {
_pauseCompleter = null;
});
_channel.sink.add(<String, String>{'command': 'displayPause'});
return _pauseCompleter!.operation;
}
/// The callback for handling messages received from the host page.
void _onMessage(dynamic message) {
assert(message is Map<String, dynamic>);
if (message is Map<String, dynamic>) {
switch (message['command'] as String?) {
case 'ping':
break;
case 'restart':
_onRestartController.add(null);
case 'resume':
if (_pauseCompleter != null) {
_pauseCompleter!.complete();
}
default:
// Unreachable.
assert(false);
break;
}
}
}
/// Closes the manager and releases any resources it owns, including closing
/// the browser.
Future<dynamic> 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 bool supportsDebugging = true;
@override
final Uri? observatoryUrl;
@override
final Uri remoteDebuggerUrl;
@override
final Stream<dynamic> onRestart;
@override
CancelableOperation<dynamic> displayPause() => _manager._displayPause();
}