• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// ignore_for_file: implementation_imports
6
7import 'dart:async';
8
9import 'package:async/async.dart';
10import 'package:http_multi_server/http_multi_server.dart';
11import 'package:path/path.dart' as p; // ignore: package_path_import
12import 'package:pool/pool.dart';
13import 'package:shelf/shelf.dart' as shelf;
14import 'package:shelf/shelf_io.dart' as shelf_io;
15import 'package:shelf_packages_handler/shelf_packages_handler.dart';
16import 'package:shelf_static/shelf_static.dart';
17import 'package:shelf_web_socket/shelf_web_socket.dart';
18import 'package:stream_channel/stream_channel.dart';
19import 'package:test_api/backend.dart';
20import 'package:test_api/src/backend/runtime.dart';
21import 'package:test_api/src/backend/suite_platform.dart';
22import 'package:test_api/src/util/stack_trace_mapper.dart';
23import 'package:test_core/src/runner/configuration.dart';
24import 'package:test_core/src/runner/environment.dart';
25import 'package:test_core/src/runner/platform.dart';
26import 'package:test_core/src/runner/plugin/platform_helpers.dart';
27import 'package:test_core/src/runner/runner_suite.dart';
28import 'package:test_core/src/runner/suite.dart';
29import 'package:web_socket_channel/web_socket_channel.dart';
30
31import '../artifacts.dart';
32import '../base/common.dart';
33import '../base/file_system.dart';
34import '../cache.dart';
35import '../convert.dart';
36import '../dart/package_map.dart';
37import '../globals.dart';
38import '../web/chrome.dart';
39
40class FlutterWebPlatform extends PlatformPlugin {
41  FlutterWebPlatform._(this._server, this._config, this._root) {
42    // Look up the location of the testing resources.
43    final Map<String, Uri> packageMap = PackageMap(fs.path.join(
44      Cache.flutterRoot,
45      'packages',
46      'flutter_tools',
47      '.packages',
48    )).map;
49    testUri = packageMap['test'];
50    final shelf.Cascade cascade = shelf.Cascade()
51        .add(_webSocketHandler.handler)
52        .add(packagesDirHandler())
53        .add(_jsHandler.handler)
54        .add(createStaticHandler(
55          fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'),
56          serveFilesOutsidePath: true,
57        ))
58        .add(createStaticHandler(_config.suiteDefaults.precompiledPath,
59            serveFilesOutsidePath: true))
60        .add(_handleStaticArtifact)
61        .add(_wrapperHandler);
62    _server.mount(cascade.handler);
63  }
64
65  static Future<FlutterWebPlatform> start(String root) async {
66    final shelf_io.IOServer server =
67        shelf_io.IOServer(await HttpMultiServer.loopback(0));
68    return FlutterWebPlatform._(
69      server,
70      Configuration.current,
71      root,
72    );
73  }
74
75  Uri testUri;
76
77  /// The test runner configuration.
78  final Configuration _config;
79
80  /// The underlying server.
81  final shelf.Server _server;
82
83  /// The URL for this server.
84  Uri get url => _server.url;
85
86  /// The ahem text file.
87  File get ahem => fs.file(fs.path.join(
88        Cache.flutterRoot,
89        'packages',
90        'flutter_tools',
91        'static',
92        'Ahem.ttf',
93      ));
94
95  /// The require js binary.
96  File get requireJs => fs.file(fs.path.join(
97        artifacts.getArtifactPath(Artifact.engineDartSdkPath),
98        'lib',
99        'dev_compiler',
100        'amd',
101        'require.js',
102      ));
103
104  /// The ddc to dart stack trace mapper.
105  File get stackTraceMapper => fs.file(fs.path.join(
106        artifacts.getArtifactPath(Artifact.engineDartSdkPath),
107        'lib',
108        'dev_compiler',
109        'web',
110        'dart_stack_trace_mapper.js',
111      ));
112
113  /// The precompiled dart sdk.
114  File get dartSdk => fs.file(fs.path.join(
115        artifacts.getArtifactPath(Artifact.flutterWebSdk),
116        'kernel',
117        'amd',
118        'dart_sdk.js',
119      ));
120
121  /// The precompiled test javascript.
122  File get testDartJs => fs.file(fs.path.join(
123        testUri.toFilePath(),
124        'dart.js',
125      ));
126
127  File get testHostDartJs => fs.file(fs.path.join(
128        testUri.toFilePath(),
129        'src',
130        'runner',
131        'browser',
132        'static',
133        'host.dart.js',
134      ));
135
136  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
137    if (request.requestedUri.path.contains('require.js')) {
138      return shelf.Response.ok(
139        requireJs.openRead(),
140        headers: <String, String>{'Content-Type': 'text/javascript'},
141      );
142    } else if (request.requestedUri.path.contains('Ahem.ttf')) {
143      return shelf.Response.ok(ahem.openRead());
144    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
145      return shelf.Response.ok(
146        dartSdk.openRead(),
147        headers: <String, String>{'Content-Type': 'text/javascript'},
148      );
149    } else if (request.requestedUri.path
150        .contains('stack_trace_mapper.dart.js')) {
151      return shelf.Response.ok(
152        stackTraceMapper.openRead(),
153        headers: <String, String>{'Content-Type': 'text/javascript'},
154      );
155    } else if (request.requestedUri.path.contains('static/dart.js')) {
156      return shelf.Response.ok(
157        testDartJs.openRead(),
158        headers: <String, String>{'Content-Type': 'text/javascript'},
159      );
160    } else if (request.requestedUri.path.contains('host.dart.js')) {
161      return shelf.Response.ok(
162        testHostDartJs.openRead(),
163        headers: <String, String>{'Content-Type': 'text/javascript'},
164      );
165    } else {
166      return shelf.Response.notFound('Not Found');
167    }
168  }
169
170  final OneOffHandler _webSocketHandler = OneOffHandler();
171  final PathHandler _jsHandler = PathHandler();
172  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
173  final String _root;
174
175  bool get _closed => _closeMemo.hasRun;
176
177  // A map from browser identifiers to futures that will complete to the
178  // [BrowserManager]s for those browsers, or `null` if they failed to load.
179  final Map<Runtime, Future<BrowserManager>> _browserManagers =
180      <Runtime, Future<BrowserManager>>{};
181
182  // Mappers for Dartifying stack traces, indexed by test path.
183  final Map<String, StackTraceMapper> _mappers = <String, StackTraceMapper>{};
184
185  // A handler that serves wrapper files used to bootstrap tests.
186  shelf.Response _wrapperHandler(shelf.Request request) {
187    final String path = fs.path.fromUri(request.url);
188    if (path.endsWith('.html')) {
189      final String test = fs.path.withoutExtension(path) + '.dart';
190      final String scriptBase = htmlEscape.convert(fs.path.basename(test));
191      final String link = '<link rel="x-dart-test" href="$scriptBase">';
192      return shelf.Response.ok('''
193        <!DOCTYPE html>
194        <html>
195        <head>
196          <title>${htmlEscape.convert(test)} Test</title>
197          $link
198          <script src="static/dart.js"></script>
199        </head>
200        </html>
201      ''', headers: <String, String>{'Content-Type': 'text/html'});
202    }
203    printTrace('Did not find anything for request: ${request.url}');
204    return shelf.Response.notFound('Not found.');
205  }
206
207  @override
208  Future<RunnerSuite> load(String path, SuitePlatform platform,
209      SuiteConfiguration suiteConfig, Object message) async {
210    if (_closed) {
211      return null;
212    }
213    final Runtime browser = platform.runtime;
214    final BrowserManager browserManager = await _browserManagerFor(browser);
215    if (_closed || browserManager == null) {
216      return null;
217    }
218
219    final Uri suiteUrl = url.resolveUri(fs.path.toUri(fs.path.withoutExtension(
220            fs.path.relative(path, from: fs.path.join(_root, 'test'))) +
221        '.html'));
222    final RunnerSuite suite = await browserManager
223        .load(path, suiteUrl, suiteConfig, message, mapper: _mappers[path]);
224    if (_closed) {
225      return null;
226    }
227    return suite;
228  }
229
230  @override
231  StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) =>
232      throw UnimplementedError();
233
234  /// Returns the [BrowserManager] for [runtime], which should be a browser.
235  ///
236  /// If no browser manager is running yet, starts one.
237  Future<BrowserManager> _browserManagerFor(Runtime browser) {
238    final Future<BrowserManager> managerFuture = _browserManagers[browser];
239    if (managerFuture != null) {
240      return managerFuture;
241    }
242    final Completer<WebSocketChannel> completer =
243        Completer<WebSocketChannel>.sync();
244    final String path =
245        _webSocketHandler.create(webSocketHandler(completer.complete));
246    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
247    final Uri hostUrl = url
248        .resolve('static/index.html')
249        .replace(queryParameters: <String, String>{
250      'managerUrl': webSocketUrl.toString(),
251      'debug': _config.pauseAfterLoad.toString()
252    });
253
254    printTrace('Serving tests at $hostUrl');
255
256    final Future<BrowserManager> future = BrowserManager.start(
257      browser,
258      hostUrl,
259      completer.future,
260    );
261
262    // Store null values for browsers that error out so we know not to load them
263    // again.
264    _browserManagers[browser] = future.catchError((dynamic _) => null);
265
266    return future;
267  }
268
269  @override
270  Future<void> closeEphemeral() {
271    final List<Future<BrowserManager>> managers =
272        _browserManagers.values.toList();
273    _browserManagers.clear();
274    return Future.wait(managers.map((Future<BrowserManager> manager) async {
275      final BrowserManager result = await manager;
276      if (result == null) {
277        return;
278      }
279      await result.close();
280    }));
281  }
282
283  @override
284  Future<void> close() => _closeMemo.runOnce(() async {
285        final List<Future<dynamic>> futures = _browserManagers.values
286            .map<Future<dynamic>>((Future<BrowserManager> future) async {
287          final BrowserManager result = await future;
288          if (result == null) {
289            return;
290          }
291          await result.close();
292        }).toList();
293        futures.add(_server.close());
294        await Future.wait<void>(futures);
295      });
296}
297
298class OneOffHandler {
299  /// A map from URL paths to handlers.
300  final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{};
301
302  /// The counter of handlers that have been activated.
303  int _counter = 0;
304
305  /// The actual [shelf.Handler] that dispatches requests.
306  shelf.Handler get handler => _onRequest;
307
308  /// Creates a new one-off handler that forwards to [handler].
309  ///
310  /// Returns a string that's the URL path for hitting this handler, relative to
311  /// the URL for the one-off handler itself.
312  ///
313  /// [handler] will be unmounted as soon as it receives a request.
314  String create(shelf.Handler handler) {
315    final String path = _counter.toString();
316    _handlers[path] = handler;
317    _counter++;
318    return path;
319  }
320
321  /// Dispatches [request] to the appropriate handler.
322  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
323    final List<String> components = p.url.split(request.url.path);
324    if (components.isEmpty) {
325      return shelf.Response.notFound(null);
326    }
327    final String path = components.removeAt(0);
328    final FutureOr<shelf.Response> Function(shelf.Request) handler =
329        _handlers.remove(path);
330    if (handler == null) {
331      return shelf.Response.notFound(null);
332    }
333    return handler(request.change(path: path));
334  }
335}
336
337class PathHandler {
338  /// A trie of path components to handlers.
339  final _Node _paths = _Node();
340
341  /// The shelf handler.
342  shelf.Handler get handler => _onRequest;
343
344  /// Returns middleware that nests all requests beneath the URL prefix
345  /// [beneath].
346  static shelf.Middleware nestedIn(String beneath) {
347    return (FutureOr<shelf.Response> Function(shelf.Request) handler) {
348      final PathHandler pathHandler = PathHandler()..add(beneath, handler);
349      return pathHandler.handler;
350    };
351  }
352
353  /// Routes requests at or under [path] to [handler].
354  ///
355  /// If [path] is a parent or child directory of another path in this handler,
356  /// the longest matching prefix wins.
357  void add(String path, shelf.Handler handler) {
358    _Node node = _paths;
359    for (String component in p.url.split(path)) {
360      node = node.children.putIfAbsent(component, () => _Node());
361    }
362    node.handler = handler;
363  }
364
365  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
366    shelf.Handler handler;
367    int handlerIndex;
368    _Node node = _paths;
369    final List<String> components = p.url.split(request.url.path);
370    for (int i = 0; i < components.length; i++) {
371      node = node.children[components[i]];
372      if (node == null) {
373        break;
374      }
375      if (node.handler == null) {
376        continue;
377      }
378      handler = node.handler;
379      handlerIndex = i;
380    }
381
382    if (handler == null) {
383      return shelf.Response.notFound('Not found.');
384    }
385
386    return handler(
387        request.change(path: p.url.joinAll(components.take(handlerIndex + 1))));
388  }
389}
390
391/// A trie node.
392class _Node {
393  shelf.Handler handler;
394  final Map<String, _Node> children = <String, _Node>{};
395}
396
397class BrowserManager {
398  /// Creates a new BrowserManager that communicates with [browser] over
399  /// [webSocket].
400  BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) {
401    // The duration should be short enough that the debugging console is open as
402    // soon as the user is done setting breakpoints, but long enough that a test
403    // doing a lot of synchronous work doesn't trigger a false positive.
404    //
405    // Start this canceled because we don't want it to start ticking until we
406    // get some response from the iframe.
407    _timer = RestartableTimer(const Duration(seconds: 3), () {
408      for (RunnerSuiteController controller in _controllers) {
409        controller.setDebugging(true);
410      }
411    })
412      ..cancel();
413
414    // Whenever we get a message, no matter which child channel it's for, we the
415    // know browser is still running code which means the user isn't debugging.
416    _channel = MultiChannel<dynamic>(
417        webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
418      return stream.map((Object message) {
419        if (!_closed) {
420          _timer.reset();
421        }
422        for (RunnerSuiteController controller in _controllers) {
423          controller.setDebugging(false);
424        }
425
426        return message;
427      });
428    }));
429
430    _environment = _loadBrowserEnvironment();
431    _channel.stream.listen(_onMessage, onDone: close);
432  }
433
434  /// The browser instance that this is connected to via [_channel].
435  final Chrome _browser;
436
437  // TODO(nweiz): Consider removing the duplication between this and
438  // [_browser.name].
439  /// The [Runtime] for [_browser].
440  final Runtime _runtime;
441
442  /// The channel used to communicate with the browser.
443  ///
444  /// This is connected to a page running `static/host.dart`.
445  MultiChannel<dynamic> _channel;
446
447  /// A pool that ensures that limits the number of initial connections the
448  /// manager will wait for at once.
449  ///
450  /// This isn't the *total* number of connections; any number of iframes may be
451  /// loaded in the same browser. However, the browser can only load so many at
452  /// once, and we want a timeout in case they fail so we only wait for so many
453  /// at once.
454  final Pool _pool = Pool(8);
455
456  /// The ID of the next suite to be loaded.
457  ///
458  /// This is used to ensure that the suites can be referred to consistently
459  /// across the client and server.
460  int _suiteID = 0;
461
462  /// Whether the channel to the browser has closed.
463  bool _closed = false;
464
465  /// The completer for [_BrowserEnvironment.displayPause].
466  ///
467  /// This will be `null` as long as the browser isn't displaying a pause
468  /// screen.
469  CancelableCompleter<dynamic> _pauseCompleter;
470
471  /// The controller for [_BrowserEnvironment.onRestart].
472  final StreamController<dynamic> _onRestartController =
473      StreamController<dynamic>.broadcast();
474
475  /// The environment to attach to each suite.
476  Future<_BrowserEnvironment> _environment;
477
478  /// Controllers for every suite in this browser.
479  ///
480  /// These are used to mark suites as debugging or not based on the browser's
481  /// pings.
482  final Set<RunnerSuiteController> _controllers = <RunnerSuiteController>{};
483
484  // A timer that's reset whenever we receive a message from the browser.
485  //
486  // Because the browser stops running code when the user is actively debugging,
487  // this lets us detect whether they're debugging reasonably accurately.
488  RestartableTimer _timer;
489
490  final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>();
491
492  /// Starts the browser identified by [runtime] and has it connect to [url].
493  ///
494  /// [url] should serve a page that establishes a WebSocket connection with
495  /// this process. That connection, once established, should be emitted via
496  /// [future]. If [debug] is true, starts the browser in debug mode, with its
497  /// debugger interfaces on and detected.
498  ///
499  /// The [settings] indicate how to invoke this browser's executable.
500  ///
501  /// Returns the browser manager, or throws an [ApplicationException] if a
502  /// connection fails to be established.
503  static Future<BrowserManager> start(
504      Runtime runtime, Uri url, Future<WebSocketChannel> future,
505      {bool debug = false}) async {
506    final Chrome chrome =
507        await chromeLauncher.launch(url.toString(), headless: true);
508
509    final Completer<BrowserManager> completer = Completer<BrowserManager>();
510
511    unawaited(chrome.onExit.then((void _) {
512      throwToolExit('${runtime.name} exited before connecting.');
513    }).catchError((dynamic error, StackTrace stackTrace) {
514      if (completer.isCompleted) {
515        return;
516      }
517      completer.completeError(error, stackTrace);
518    }));
519    unawaited(future.then((WebSocketChannel webSocket) {
520      if (completer.isCompleted) {
521        return;
522      }
523      completer.complete(BrowserManager._(chrome, runtime, webSocket));
524    }).catchError((dynamic error, StackTrace stackTrace) {
525      chrome.close();
526      if (completer.isCompleted) {
527        return;
528      }
529      completer.completeError(error, stackTrace);
530    }));
531
532    return completer.future.timeout(const Duration(seconds: 30), onTimeout: () {
533      chrome.close();
534      throwToolExit('Timed out waiting for ${runtime.name} to connect.');
535      return;
536    });
537  }
538
539  /// Loads [_BrowserEnvironment].
540  Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
541    return _BrowserEnvironment(
542        this, null, _browser.remoteDebuggerUri, _onRestartController.stream);
543  }
544
545  /// Tells the browser the load a test suite from the URL [url].
546  ///
547  /// [url] should be an HTML page with a reference to the JS-compiled test
548  /// suite. [path] is the path of the original test suite file, which is used
549  /// for reporting. [suiteConfig] is the configuration for the test suite.
550  ///
551  /// If [mapper] is passed, it's used to map stack traces for errors coming
552  /// from this test suite.
553  Future<RunnerSuite> load(
554      String path, Uri url, SuiteConfiguration suiteConfig, Object message,
555      {StackTraceMapper mapper}) async {
556    url = url.replace(
557        fragment: Uri.encodeFull(jsonEncode(<String, Object>{
558      'metadata': suiteConfig.metadata.serialize(),
559      'browser': _runtime.identifier
560    })));
561
562    final int suiteID = _suiteID++;
563    RunnerSuiteController controller;
564    void closeIframe() {
565      if (_closed) {
566        return;
567      }
568      _controllers.remove(controller);
569      _channel.sink
570          .add(<String, Object>{'command': 'closeSuite', 'id': suiteID});
571    }
572
573    // The virtual channel will be closed when the suite is closed, in which
574    // case we should unload the iframe.
575    final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel();
576    final int suiteChannelID = virtualChannel.id;
577    final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
578        StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
579      closeIframe();
580      sink.close();
581    }));
582
583    return await _pool.withResource<RunnerSuite>(() async {
584      _channel.sink.add(<String, Object>{
585        'command': 'loadSuite',
586        'url': url.toString(),
587        'id': suiteID,
588        'channel': suiteChannelID
589      });
590
591      try {
592        controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
593            suiteConfig, await _environment, suiteChannel, message);
594        controller.channel('test.browser.mapper').sink.add(mapper?.serialize());
595
596        _controllers.add(controller);
597        return await controller.suite;
598      } catch (_) {
599        closeIframe();
600        rethrow;
601      }
602    });
603  }
604
605  /// An implementation of [Environment.displayPause].
606  CancelableOperation<dynamic> _displayPause() {
607    if (_pauseCompleter != null) {
608      return _pauseCompleter.operation;
609    }
610    _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
611      _channel.sink.add(<String, String>{'command': 'resume'});
612      _pauseCompleter = null;
613    });
614    _pauseCompleter.operation.value.whenComplete(() {
615      _pauseCompleter = null;
616    });
617    _channel.sink.add(<String, String>{'command': 'displayPause'});
618
619    return _pauseCompleter.operation;
620  }
621
622  /// The callback for handling messages received from the host page.
623  void _onMessage(dynamic message) {
624    switch (message['command'] as String) {
625      case 'ping':
626        break;
627      case 'restart':
628        _onRestartController.add(null);
629        break;
630      case 'resume':
631        if (_pauseCompleter != null) {
632          _pauseCompleter.complete();
633        }
634        break;
635      default:
636        // Unreachable.
637        assert(false);
638        break;
639    }
640  }
641
642  /// Closes the manager and releases any resources it owns, including closing
643  /// the browser.
644  Future<dynamic> close() {
645    return _closeMemoizer.runOnce(() {
646      _closed = true;
647      _timer.cancel();
648      if (_pauseCompleter != null) {
649        _pauseCompleter.complete();
650      }
651      _pauseCompleter = null;
652      _controllers.clear();
653      return _browser.close();
654    });
655  }
656}
657
658/// An implementation of [Environment] for the browser.
659///
660/// All methods forward directly to [BrowserManager].
661class _BrowserEnvironment implements Environment {
662  _BrowserEnvironment(this._manager, this.observatoryUrl,
663      this.remoteDebuggerUrl, this.onRestart);
664
665  final BrowserManager _manager;
666
667  @override
668  final bool supportsDebugging = true;
669
670  @override
671  final Uri observatoryUrl;
672
673  @override
674  final Uri remoteDebuggerUrl;
675
676  @override
677  final Stream<dynamic> onRestart;
678
679  @override
680  CancelableOperation<dynamic> displayPause() => _manager._displayPause();
681}
682