blob: e487a9c77562e62fbe39c709a76905957bb99906 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: implementation_imports
import 'dart:async';
import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:path/path.dart' as p; // ignore: package_path_import
import 'package:pool/pool.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_packages_handler/shelf_packages_handler.dart';
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/backend.dart';
import 'package:test_api/src/backend/runtime.dart';
import 'package:test_api/src/backend/suite_platform.dart';
import 'package:test_api/src/util/stack_trace_mapper.dart';
import 'package:test_core/src/runner/configuration.dart';
import 'package:test_core/src/runner/environment.dart';
import 'package:test_core/src/runner/platform.dart';
import 'package:test_core/src/runner/plugin/platform_helpers.dart';
import 'package:test_core/src/runner/runner_suite.dart';
import 'package:test_core/src/runner/suite.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../cache.dart';
import '../convert.dart';
import '../dart/package_map.dart';
import '../globals.dart';
import '../web/chrome.dart';
class FlutterWebPlatform extends PlatformPlugin {
FlutterWebPlatform._(this._server, this._config, this._root) {
// Look up the location of the testing resources.
final Map<String, Uri> packageMap = PackageMap(fs.path.join(
Cache.flutterRoot,
'packages',
'flutter_tools',
'.packages',
)).map;
testUri = packageMap['test'];
final shelf.Cascade cascade = shelf.Cascade()
.add(_webSocketHandler.handler)
.add(packagesDirHandler())
.add(_jsHandler.handler)
.add(createStaticHandler(
fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'),
serveFilesOutsidePath: true,
))
.add(createStaticHandler(_config.suiteDefaults.precompiledPath,
serveFilesOutsidePath: true))
.add(_handleStaticArtifact)
.add(_wrapperHandler);
_server.mount(cascade.handler);
}
static Future<FlutterWebPlatform> start(String root) async {
final shelf_io.IOServer server =
shelf_io.IOServer(await HttpMultiServer.loopback(0));
return FlutterWebPlatform._(
server,
Configuration.current,
root,
);
}
Uri testUri;
/// The test runner configuration.
final Configuration _config;
/// The underlying server.
final shelf.Server _server;
/// The URL for this server.
Uri get url => _server.url;
/// The ahem text file.
File get ahem => fs.file(fs.path.join(
Cache.flutterRoot,
'packages',
'flutter_tools',
'static',
'Ahem.ttf',
));
/// The require js binary.
File get requireJs => fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.engineDartSdkPath),
'lib',
'dev_compiler',
'amd',
'require.js',
));
/// The ddc to dart stack trace mapper.
File get stackTraceMapper => fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.engineDartSdkPath),
'lib',
'dev_compiler',
'web',
'dart_stack_trace_mapper.js',
));
/// The precompiled dart sdk.
File get dartSdk => fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.flutterWebSdk),
'kernel',
'amd',
'dart_sdk.js',
));
/// The precompiled test javascript.
File get testDartJs => fs.file(fs.path.join(
testUri.toFilePath(),
'dart.js',
));
File get testHostDartJs => fs.file(fs.path.join(
testUri.toFilePath(),
'src',
'runner',
'browser',
'static',
'host.dart.js',
));
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('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('stack_trace_mapper.dart.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 {
return shelf.Response.notFound('Not Found');
}
}
final OneOffHandler _webSocketHandler = OneOffHandler();
final PathHandler _jsHandler = PathHandler();
final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
final String _root;
bool get _closed => _closeMemo.hasRun;
// A map from browser identifiers to futures that will complete to the
// [BrowserManager]s for those browsers, or `null` if they failed to load.
final Map<Runtime, Future<BrowserManager>> _browserManagers =
<Runtime, Future<BrowserManager>>{};
// Mappers for Dartifying stack traces, indexed by test path.
final Map<String, StackTraceMapper> _mappers = <String, StackTraceMapper>{};
// A handler that serves wrapper files used to bootstrap tests.
shelf.Response _wrapperHandler(shelf.Request request) {
final String path = fs.path.fromUri(request.url);
if (path.endsWith('.html')) {
final String test = fs.path.withoutExtension(path) + '.dart';
final String scriptBase = htmlEscape.convert(fs.path.basename(test));
final String link = '<link rel="x-dart-test" href="$scriptBase">';
return shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${htmlEscape.convert(test)} Test</title>
$link
<script src="static/dart.js"></script>
</head>
</html>
''', headers: <String, String>{'Content-Type': 'text/html'});
}
printTrace('Did not find anything for request: ${request.url}');
return shelf.Response.notFound('Not found.');
}
@override
Future<RunnerSuite> load(String path, SuitePlatform platform,
SuiteConfiguration suiteConfig, Object message) async {
if (_closed) {
return null;
}
final Runtime browser = platform.runtime;
final BrowserManager browserManager = await _browserManagerFor(browser);
if (_closed || browserManager == null) {
return null;
}
final Uri suiteUrl = url.resolveUri(fs.path.toUri(fs.path.withoutExtension(
fs.path.relative(path, from: fs.path.join(_root, 'test'))) +
'.html'));
final RunnerSuite suite = await browserManager
.load(path, suiteUrl, suiteConfig, message, mapper: _mappers[path]);
if (_closed) {
return null;
}
return suite;
}
@override
StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) =>
throw UnimplementedError();
/// Returns the [BrowserManager] for [runtime], which should be a browser.
///
/// If no browser manager is running yet, starts one.
Future<BrowserManager> _browserManagerFor(Runtime browser) {
final Future<BrowserManager> managerFuture = _browserManagers[browser];
if (managerFuture != null) {
return managerFuture;
}
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()
});
printTrace('Serving tests at $hostUrl');
final Future<BrowserManager> future = BrowserManager.start(
browser,
hostUrl,
completer.future,
);
// Store null values for browsers that error out so we know not to load them
// again.
_browserManagers[browser] = future.catchError((dynamic _) => null);
return future;
}
@override
Future<void> closeEphemeral() {
final List<Future<BrowserManager>> managers =
_browserManagers.values.toList();
_browserManagers.clear();
return Future.wait(managers.map((Future<BrowserManager> manager) async {
final BrowserManager result = await manager;
if (result == null) {
return;
}
await result.close();
}));
}
@override
Future<void> close() => _closeMemo.runOnce(() async {
final List<Future<dynamic>> futures = _browserManagers.values
.map<Future<dynamic>>((Future<BrowserManager> future) async {
final BrowserManager result = await future;
if (result == null) {
return;
}
await result.close();
}).toList();
futures.add(_server.close());
await Future.wait<void>(futures);
});
}
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 = p.url.split(request.url.path);
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 PathHandler {
/// A trie of path components to handlers.
final _Node _paths = _Node();
/// The shelf handler.
shelf.Handler get handler => _onRequest;
/// Returns middleware that nests all requests beneath the URL prefix
/// [beneath].
static shelf.Middleware nestedIn(String beneath) {
return (FutureOr<shelf.Response> Function(shelf.Request) handler) {
final PathHandler pathHandler = PathHandler()..add(beneath, handler);
return pathHandler.handler;
};
}
/// Routes requests at or under [path] to [handler].
///
/// If [path] is a parent or child directory of another path in this handler,
/// the longest matching prefix wins.
void add(String path, shelf.Handler handler) {
_Node node = _paths;
for (String component in p.url.split(path)) {
node = node.children.putIfAbsent(component, () => _Node());
}
node.handler = handler;
}
FutureOr<shelf.Response> _onRequest(shelf.Request request) {
shelf.Handler handler;
int handlerIndex;
_Node node = _paths;
final List<String> components = p.url.split(request.url.path);
for (int i = 0; i < components.length; i++) {
node = node.children[components[i]];
if (node == null) {
break;
}
if (node.handler == null) {
continue;
}
handler = node.handler;
handlerIndex = i;
}
if (handler == null) {
return shelf.Response.notFound('Not found.');
}
return handler(
request.change(path: p.url.joinAll(components.take(handlerIndex + 1))));
}
}
/// A trie node.
class _Node {
shelf.Handler handler;
final Map<String, _Node> children = <String, _Node>{};
}
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 (RunnerSuiteController 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<dynamic>(
webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
return stream.map((Object message) {
if (!_closed) {
_timer.reset();
}
for (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 Chrome _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`.
MultiChannel<dynamic> _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 = 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<dynamic> _pauseCompleter;
/// The controller for [_BrowserEnvironment.onRestart].
final StreamController<dynamic> _onRestartController =
StreamController<dynamic>.broadcast();
/// The environment to attach to each suite.
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.
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 [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,
{bool debug = false}) async {
final Chrome chrome =
await chromeLauncher.launch(url.toString(), headless: true);
final Completer<BrowserManager> completer = Completer<BrowserManager>();
unawaited(chrome.onExit.then((void _) {
throwToolExit('${runtime.name} exited before connecting.');
}).catchError((dynamic error, StackTrace stackTrace) {
if (completer.isCompleted) {
return;
}
completer.completeError(error, stackTrace);
}));
unawaited(future.then((WebSocketChannel webSocket) {
if (completer.isCompleted) {
return;
}
completer.complete(BrowserManager._(chrome, runtime, webSocket));
}).catchError((dynamic error, StackTrace stackTrace) {
chrome.close();
if (completer.isCompleted) {
return;
}
completer.completeError(error, stackTrace);
}));
return completer.future.timeout(const Duration(seconds: 30), onTimeout: () {
chrome.close();
throwToolExit('Timed out waiting for ${runtime.name} to connect.');
return;
});
}
/// Loads [_BrowserEnvironment].
Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
return _BrowserEnvironment(
this, null, _browser.remoteDebuggerUri, _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, Object message,
{StackTraceMapper mapper}) 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();
}));
return await _pool.withResource<RunnerSuite>(() async {
_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);
controller.channel('test.browser.mapper').sink.add(mapper?.serialize());
_controllers.add(controller);
return await controller.suite;
} catch (_) {
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) {
switch (message['command'] as String) {
case 'ping':
break;
case 'restart':
_onRestartController.add(null);
break;
case 'resume':
if (_pauseCompleter != null) {
_pauseCompleter.complete();
}
break;
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();
}