Reland support flutter test on platform chrome (#33859)
diff --git a/packages/flutter_tools/build.yaml b/packages/flutter_tools/build.yaml index 69fa907..c2d33c80 100644 --- a/packages/flutter_tools/build.yaml +++ b/packages/flutter_tools/build.yaml
@@ -1,5 +1,8 @@ targets: $default: + builders: + build_web_compilers|entrypoint: + enabled: false sources: exclude: - "test/data/**"
diff --git a/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart b/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart index 913746b..0ff1a9c 100644 --- a/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart +++ b/packages/flutter_tools/lib/src/build_runner/web_compilation_delegate.dart
@@ -12,17 +12,21 @@ import 'package:build_runner_core/build_runner_core.dart' as core; import 'package:build_runner_core/src/generate/build_impl.dart'; import 'package:build_runner_core/src/generate/options.dart'; +import 'package:build_test/builder.dart'; +import 'package:build_test/src/debug_test_builder.dart'; import 'package:build_web_compilers/build_web_compilers.dart'; import 'package:build_web_compilers/builders.dart'; import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; +import 'package:test_core/backend.dart'; import 'package:watcher/watcher.dart'; import '../artifacts.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/platform.dart'; import '../compile.dart'; import '../dart/package_map.dart'; import '../globals.dart'; @@ -66,6 +70,20 @@ /// The build application to compile a flutter application to the web. final List<core.BuilderApplication> builders = <core.BuilderApplication>[ core.apply( + 'flutter_tools|test_bootstrap', + <BuilderFactory>[ + (BuilderOptions options) => const DebugTestBuilder(), + (BuilderOptions options) => const FlutterWebTestBootstrapBuilder(), + ], + core.toRoot(), + hideOutput: true, + defaultGenerateFor: const InputSet( + include: <String>[ + 'test/**', + ], + ), + ), + core.apply( 'flutter_tools|module_library', <Builder Function(BuilderOptions)>[moduleLibraryBuilder], core.toAllPackages(), @@ -109,7 +127,7 @@ 'flutter_tools|entrypoint', <BuilderFactory>[ (BuilderOptions options) => FlutterWebEntrypointBuilder( - options.config['target'] ?? 'lib/main.dart'), + options.config['targets'] ?? <String>['lib/main.dart']), ], core.toRoot(), hideOutput: true, @@ -117,6 +135,7 @@ include: <String>[ 'lib/**', 'web/**', + 'test/**_test.dart.browser_test.dart', ], ), ), @@ -135,13 +154,14 @@ @override Future<void> initialize({ @required Directory projectDirectory, - @required String target, + @required List<String> targets, + String testOutputDir, }) async { // Override the generated output directory so this does not conflict with // other build_runner output. core.overrideGeneratedOutputDirectory('flutter_web'); _packageUriMapper = PackageUriMapper( - path.absolute(target), PackageMap.globalPackagesPath, null, null); + path.absolute('lib/main.dart'), PackageMap.globalPackagesPath, null, null); _packageGraph = core.PackageGraph.forPath(projectDirectory.path); final core.BuildEnvironment buildEnvironment = core.OverrideableEnvironment( core.IOEnvironment(_packageGraph), onLog: (LogRecord record) { @@ -163,8 +183,18 @@ trackPerformance: false, deleteFilesByDefault: true, ); + final Set<core.BuildDirectory> buildDirs = <core.BuildDirectory>{ + if (testOutputDir != null) + core.BuildDirectory( + 'test', + outputLocation: core.OutputLocation( + testOutputDir, + useSymlinks: !platform.isWindows, + ), + ), + }; final Status status = - logger.startProgress('Compiling $target for the Web...', timeout: null); + logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null); try { _builder = await BuildImpl.create( buildOptions, @@ -172,12 +202,12 @@ builders, <String, Map<String, dynamic>>{ 'flutter_tools|entrypoint': <String, dynamic>{ - 'target': target, + 'targets': targets, } }, isReleaseBuild: false, ); - await _builder.run(const <AssetId, ChangeType>{}); + await _builder.run(const <AssetId, ChangeType>{}, buildDirs: buildDirs); } finally { status.stop(); } @@ -205,9 +235,9 @@ /// A ddc-only entrypoint builder that respects the Flutter target flag. class FlutterWebEntrypointBuilder implements Builder { - const FlutterWebEntrypointBuilder(this.target); + const FlutterWebEntrypointBuilder(this.targets); - final String target; + final List<String> targets; @override Map<String, List<String>> get buildExtensions => const <String, List<String>>{ @@ -222,10 +252,123 @@ @override Future<void> build(BuildStep buildStep) async { - if (!buildStep.inputId.path.contains(target)) { + bool matches = false; + for (String target in targets) { + if (buildStep.inputId.path.contains(target)) { + matches = true; + break; + } + } + if (!matches) { return; } log.info('building for target ${buildStep.inputId.path}'); await bootstrapDdc(buildStep, platform: flutterWebPlatform); } } + +class FlutterWebTestBootstrapBuilder implements Builder { + const FlutterWebTestBootstrapBuilder(); + + @override + Map<String, List<String>> get buildExtensions => const <String, List<String>>{ + '_test.dart': <String>[ + '_test.dart.browser_test.dart', + ] + }; + + @override + Future<void> build(BuildStep buildStep) async { + final AssetId id = buildStep.inputId; + final String contents = await buildStep.readAsString(id); + final String assetPath = id.pathSegments.first == 'lib' + ? path.url.join('packages', id.package, id.path) + : id.path; + final Metadata metadata = parseMetadata( + assetPath, contents, Runtime.builtIn.map((Runtime runtime) => runtime.name).toSet()); + + if (metadata.testOn.evaluate(SuitePlatform(Runtime.chrome))) { + await buildStep.writeAsString(id.addExtension('.browser_test.dart'), ''' +import 'dart:ui' as ui; +import 'dart:html'; +import 'dart:js'; + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test_api/src/backend/stack_trace_formatter.dart'; // ignore: implementation_imports +import 'package:test_api/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports +import 'package:test_api/src/remote_listener.dart'; // ignore: implementation_imports +import 'package:test_api/src/suite_channel_manager.dart'; // ignore: implementation_imports + +import "${path.url.basename(id.path)}" as test; + +Future<void> main() async { + // Extra initialization for flutter_web. + // The following parameters are hard-coded in Flutter's test embedder. Since + // we don't have an embedder yet this is the lowest-most layer we can put + // this stuff in. + await ui.webOnlyInitializeEngine(); + internalBootstrapBrowserTest(() => test.main); +} + +void internalBootstrapBrowserTest(Function getMain()) { + var channel = + serializeSuite(getMain, hidePrints: false, beforeLoad: () async { + var serialized = + await suiteChannel("test.browser.mapper").stream.first as Map; + if (serialized == null) return; + }); + postMessageChannel().pipe(channel); +} +StreamChannel serializeSuite(Function getMain(), + {bool hidePrints = true, Future beforeLoad()}) => + RemoteListener.start(getMain, + hidePrints: hidePrints, beforeLoad: beforeLoad); + +StreamChannel suiteChannel(String name) { + var manager = SuiteChannelManager.current; + if (manager == null) { + throw StateError('suiteChannel() may only be called within a test worker.'); + } + + return manager.connectOut(name); +} + +StreamChannel postMessageChannel() { + var controller = StreamChannelController(sync: true); + window.onMessage.firstWhere((message) { + return message.origin == window.location.origin && message.data == "port"; + }).then((message) { + var port = message.ports.first; + var portSubscription = port.onMessage.listen((message) { + controller.local.sink.add(message.data); + }); + + controller.local.stream.listen((data) { + port.postMessage({"data": data}); + }, onDone: () { + port.postMessage({"event": "done"}); + portSubscription.cancel(); + }); + }); + + context['parent'].callMethod('postMessage', [ + JsObject.jsify({"href": window.location.href, "ready": true}), + window.location.origin, + ]); + return controller.foreign; +} + +void setStackTraceMapper(StackTraceMapper mapper) { + var formatter = StackTraceFormatter.current; + if (formatter == null) { + throw StateError( + 'setStackTraceMapper() may only be called within a test worker.'); + } + + formatter.configure(mapper: mapper); +} +'''); + } + } +} +
diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart index 6f1caa4..d30266d 100644 --- a/packages/flutter_tools/lib/src/commands/test.dart +++ b/packages/flutter_tools/lib/src/commands/test.dart
@@ -99,6 +99,11 @@ negatable: true, help: 'Whether to build the assets bundle for testing.\n' 'Consider using --no-test-assets if assets are not required.', + ) + ..addOption('platform', + allowed: const <String>['tester', 'chrome'], + defaultsTo: 'tester', + help: 'The platform to run the unit tests on. Defaults to "tester".' ); } @@ -166,6 +171,16 @@ 'Test files must be in that directory and end with the pattern "_test.dart".' ); } + } else { + final List<String> fileCopy = <String>[]; + for (String file in files) { + if (file.endsWith(platform.pathSeparator)) { + fileCopy.addAll(_findTests(fs.directory(file))); + } else { + fileCopy.add(file); + } + } + files = fileCopy; } CoverageCollector collector; @@ -222,6 +237,7 @@ concurrency: jobs, buildTestAssets: buildTestAssets, flutterProject: flutterProject, + web: argResults['platform'] == 'chrome', ); if (collector != null) {
diff --git a/packages/flutter_tools/lib/src/resident_web_runner.dart b/packages/flutter_tools/lib/src/resident_web_runner.dart index 4777b21..b9523b8 100644 --- a/packages/flutter_tools/lib/src/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/resident_web_runner.dart
@@ -120,7 +120,7 @@ // Start the web compiler and build the assets. await webCompilationProxy.initialize( projectDirectory: currentProject.directory, - target: target, + targets: <String>[target], ); _lastCompiled = DateTime.now(); final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
diff --git a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart new file mode 100644 index 0000000..e487a9c --- /dev/null +++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
@@ -0,0 +1,681 @@ +// 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(); +}
diff --git a/packages/flutter_tools/lib/src/test/runner.dart b/packages/flutter_tools/lib/src/test/runner.dart index 2e1bc76..796a03d 100644 --- a/packages/flutter_tools/lib/src/test/runner.dart +++ b/packages/flutter_tools/lib/src/test/runner.dart
@@ -5,7 +5,9 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:test_api/backend.dart'; import 'package:test_core/src/executable.dart' as test; // ignore: implementation_imports +import 'package:test_core/src/runner/hack_register_platform.dart' as hack; // ignore: implementation_imports import '../artifacts.dart'; import '../base/common.dart'; @@ -16,7 +18,9 @@ import '../dart/package_map.dart'; import '../globals.dart'; import '../project.dart'; +import '../web/compile.dart'; import 'flutter_platform.dart' as loader; +import 'flutter_web_platform.dart'; import 'watcher.dart'; /// Runs tests using package:test and the Flutter engine. @@ -40,6 +44,7 @@ FlutterProject flutterProject, String icudtlPath, Directory coverageDirectory, + bool web = false, }) async { // Compute the command-line arguments for package:test. final List<String> testArgs = <String>[]; @@ -62,6 +67,32 @@ for (String plainName in plainNames) { testArgs..add('--plain-name')..add(plainName); } + if (web) { + final String tempBuildDir = fs.systemTempDirectory + .createTempSync('_flutter_test') + .absolute + .uri + .toFilePath(); + await webCompilationProxy.initialize( + projectDirectory: flutterProject.directory, + testOutputDir: tempBuildDir, + targets: testFiles.map((String testFile) { + return fs.path.relative(testFile, from: flutterProject.directory.path); + }).toList(), + ); + testArgs.add('--platform=chrome'); + testArgs.add('--precompiled=$tempBuildDir'); + testArgs.add('--'); + testArgs.addAll(testFiles); + hack.registerPlatformPlugin( + <Runtime>[Runtime.chrome], + () { + return FlutterWebPlatform.start(flutterProject.directory.path); + } + ); + await test.main(testArgs); + return exitCode; + } testArgs.add('--'); testArgs.addAll(testFiles);
diff --git a/packages/flutter_tools/lib/src/web/chrome.dart b/packages/flutter_tools/lib/src/web/chrome.dart index 40be47f..3d91107 100644 --- a/packages/flutter_tools/lib/src/web/chrome.dart +++ b/packages/flutter_tools/lib/src/web/chrome.dart
@@ -73,7 +73,10 @@ static final Completer<Chrome> _currentCompleter = Completer<Chrome>(); /// Launch the chrome browser to a particular `host` page. - Future<Chrome> launch(String url) async { + /// + /// `headless` defaults to false, and controls whether we open a headless or + /// a `headfull` browser. + Future<Chrome> launch(String url, { bool headless = false }) async { final String chromeExecutable = findChromeExecutable(); final Directory dataDir = fs.systemTempDirectory.createTempSync(); final int port = await os.findFreePort(); @@ -94,6 +97,8 @@ '--no-default-browser-check', '--disable-default-apps', '--disable-translate', + if (headless) + ...<String>['--headless', '--disable-gpu'], url, ]; final Process process = await processManager.start(args); @@ -107,12 +112,14 @@ throwToolExit('Unable to connect to Chrome DevTools.'); return null; }); + final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')); return _connect(Chrome._( port, ChromeConnection('localhost', port), process: process, dataDir: dataDir, + remoteDebuggerUri: remoteDebuggerUri, )); } @@ -138,15 +145,36 @@ _connect(Chrome._(port, ChromeConnection('localhost', port))); static Future<Chrome> get connectedInstance => _currentCompleter.future; + + /// Returns the full URL of the Chrome remote debugger for the main page. +/// +/// This takes the [base] remote debugger URL (which points to a browser-wide +/// page) and uses its JSON API to find the resolved URL for debugging the host +/// page. +Future<Uri> _getRemoteDebuggerUrl(Uri base) async { + try { + final HttpClient client = HttpClient(); + final HttpClientRequest request = await client.getUrl(base.resolve('/json/list')); + final HttpClientResponse response = await request.close(); + final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single; + return base.resolve(jsonObject.first['devtoolsFrontendUrl']); + } catch (_) { + // If we fail to talk to the remote debugger protocol, give up and return + // the raw URL rather than crashing. + return base; + } +} + } /// A class for managing an instance of Chrome. class Chrome { - const Chrome._( + Chrome._( this.debugPort, this.chromeConnection, { Process process, Directory dataDir, + this.remoteDebuggerUri, }) : _process = process, _dataDir = dataDir; @@ -154,15 +182,18 @@ final Process _process; final Directory _dataDir; final ChromeConnection chromeConnection; + final Uri remoteDebuggerUri; static Completer<Chrome> _currentCompleter = Completer<Chrome>(); + Future<void> get onExit => _currentCompleter.future; + Future<void> close() async { if (_currentCompleter.isCompleted) { _currentCompleter = Completer<Chrome>(); } chromeConnection.close(); - _process?.kill(ProcessSignal.SIGKILL); + _process?.kill(); await _process?.exitCode; try { // Chrome starts another process as soon as it dies that modifies the
diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart index e987498..732769a 100644 --- a/packages/flutter_tools/lib/src/web/compile.dart +++ b/packages/flutter_tools/lib/src/web/compile.dart
@@ -91,7 +91,8 @@ /// `projectDirectory`. Future<void> initialize({ @required Directory projectDirectory, - @required String target, + @required List<String> targets, + String testOutputDir, }) async { throw UnimplementedError(); }
diff --git a/packages/flutter_tools/static/index.html b/packages/flutter_tools/static/index.html new file mode 100644 index 0000000..f69739d --- /dev/null +++ b/packages/flutter_tools/static/index.html
@@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html> +<head> + <title>test Browser Host</title> +</head> +<body> + <svg id="dart" version="1.1" x="0px" y="0px" width="400px" height="400px" viewBox="0 0 400 400"> + <path id="right-flank" fill="#0083C9" d="M249.379,226.486l-6.676,15.572L166.174,166h58.82c0,0,2.807-0.409,3.645,1.966L249.379,226.486z"/> + <path id="right-ear" fill="#00D2B8" d="M201.84,141.906L166.174,166h58.82c0,0,2.168-0.25,2.645,0.566l-2.694-8.848l-15.024-14.68C207.555,140.329,203.578,140.744,201.84,141.906z"/> + <path id="left-flank" fill="#00D2B8" d="M242.616,241.856l-15.022,6.799l-60.493-21.429c-1.035-0.395-1.101-3.696-1.101-3.696v-57.932L242.616,241.856z"/> + <path id="left-paw" fill="#55DECA" d="M167.003,227.098l60.636,21.558l15.064-6.799L237.224,259h-43.856c0,0-14.077-13.929-18.141-17.993C171.162,236.943,169.162,233.989,167.003,227.098z"/> + <path id="right-paw" fill="#00A4E4" d="M227.676,166.365c0.963,1.401,1.361,2.473,1.361,2.473l20.352,57.648l-6.711,15.37L259,236.463v-44.854c0,0-13.678-13.965-17.741-17.882C237.193,169.811,231.466,166.319,227.676,166.365z"/> + <path id="left-ear" fill="#0083C9" d="M166.769,227.098c0,0-0.769-1.104-0.769-4.355v-57.144l-23.115,34.877c-1.626,1.774-1.567,6.538,1.595,9.755l13.636,13.892L166.769,227.098z"/> + </svg> + <div id="dark"></div> + <svg id="play" version="1.1" x="0px" y="0px" width="80px" height="80px" viewBox="0 0 25 25"> + <defs><filter id="blur"><feGaussianBlur stdDeviation="0.3" id="feGaussianBlur5097" /></filter></defs> + <path d="M 3.777014,1.3715789 A 1.1838119,1.1838119 0 0 0 2.693923,2.5488509 V 22.444746 a 1.1838119,1.1838119 0 0 0 1.765908,1.035999 l 17.235259,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.459831,1.5128519 A 1.1838119,1.1838119 0 0 0 3.777014,1.3715789 z" style="opacity:0.5;stroke:#000000;stroke-width:1;filter:url(#blur)" /> + <path style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.32722104" d="M 3.4770491,1.0714664 A 1.1838119,1.1838119 0 0 0 2.3939589,2.2487382 V 22.144633 a 1.1838119,1.1838119 0 0 0 1.7659079,1.035999 l 17.2352602,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.1598668,1.2127389 A 1.1838119,1.1838119 0 0 0 3.4770491,1.0714664 z" /> + </svg> + <script src="host.dart.js"></script> +</body> +</html>