flutter_web_platform.dart 26.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7
// 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';
8
import 'dart:typed_data';
9 10 11

import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
12
import 'package:meta/meta.dart';
13
import 'package:package_config/package_config.dart';
14 15 16 17 18 19
import 'package:pool/pool.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:stream_channel/stream_channel.dart';
20
import 'package:test_core/src/platform.dart';
21
import 'package:web_socket_channel/web_socket_channel.dart';
22
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;
23 24 25 26

import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
27
import '../base/io.dart';
28
import '../base/logger.dart';
29
import '../build_info.dart';
30 31 32
import '../cache.dart';
import '../convert.dart';
import '../dart/package_map.dart';
33
import '../project.dart';
34
import '../web/bootstrap.dart';
35
import '../web/chrome.dart';
36
import '../web/compile.dart';
37
import '../web/memory_fs.dart';
38
import 'flutter_web_goldens.dart';
39 40
import 'test_compiler.dart';

41
class FlutterWebPlatform extends PlatformPlugin {
42 43 44 45
  FlutterWebPlatform._(this._server, this._config, this._root, {
    FlutterProject flutterProject,
    String shellPath,
    this.updateGoldens,
46
    this.nullAssertions,
47
    @required this.buildInfo,
48
    @required this.webMemoryFS,
49 50 51 52 53 54 55 56 57 58
    @required FileSystem fileSystem,
    @required PackageConfig flutterToolPackageConfig,
    @required ChromiumLauncher chromiumLauncher,
    @required Logger logger,
    @required Artifacts artifacts,
  }) : _fileSystem = fileSystem,
      _flutterToolPackageConfig = flutterToolPackageConfig,
      _chromiumLauncher = chromiumLauncher,
      _logger = logger,
      _artifacts = artifacts {
59 60 61
    final shelf.Cascade cascade = shelf.Cascade()
        .add(_webSocketHandler.handler)
        .add(createStaticHandler(
62
          fileSystem.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'),
63 64 65
          serveFilesOutsidePath: true,
        ))
        .add(_handleStaticArtifact)
66
        .add(_goldenFileHandler)
67
        .add(_wrapperHandler)
68
        .add(_handleTestRequest)
69
        .add(createStaticHandler(
70
          fileSystem.path.join(fileSystem.currentDirectory.path, 'test'),
71 72 73
          serveFilesOutsidePath: true,
        ))
        .add(_packageFilesHandler);
74
    _server.mount(cascade.handler);
75 76
    _testGoldenComparator = TestGoldenComparator(
      shellPath,
77
      () => TestCompiler(buildInfo, flutterProject),
78
    );
79 80
  }

81
  final WebMemoryFS webMemoryFS;
82
  final BuildInfo buildInfo;
83 84 85 86 87
  final FileSystem _fileSystem;
  final PackageConfig _flutterToolPackageConfig;
  final ChromiumLauncher _chromiumLauncher;
  final Logger _logger;
  final Artifacts _artifacts;
88 89 90 91 92 93 94 95 96 97 98 99 100
  final bool updateGoldens;
  final bool nullAssertions;
  final OneOffHandler _webSocketHandler = OneOffHandler();
  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
  final String _root;

  /// 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;
  TestGoldenComparator _testGoldenComparator;
101

102 103 104 105
  static Future<FlutterWebPlatform> start(String root, {
    FlutterProject flutterProject,
    String shellPath,
    bool updateGoldens = false,
106
    bool pauseAfterLoad = false,
107
    bool nullAssertions = false,
108
    @required BuildInfo buildInfo,
109
    @required WebMemoryFS webMemoryFS,
110 111 112 113
    @required FileSystem fileSystem,
    @required Logger logger,
    @required ChromiumLauncher chromiumLauncher,
    @required Artifacts artifacts,
114
  }) async {
115
    final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
116 117 118 119 120 121 122 123 124 125
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
      fileSystem.file(fileSystem.path.join(
        Cache.flutterRoot,
        'packages',
        'flutter_tools',
        '.dart_tool',
        'package_config.json',
      )),
      logger: logger,
    );
126 127
    return FlutterWebPlatform._(
      server,
128
      Configuration.current.change(pauseAfterLoad: pauseAfterLoad),
129
      root,
130 131 132
      flutterProject: flutterProject,
      shellPath: shellPath,
      updateGoldens: updateGoldens,
133
      buildInfo: buildInfo,
134
      webMemoryFS: webMemoryFS,
135 136 137 138 139
      flutterToolPackageConfig: packageConfig,
      fileSystem: fileSystem,
      chromiumLauncher: chromiumLauncher,
      artifacts: artifacts,
      logger: logger,
140
      nullAssertions: nullAssertions,
141 142 143
    );
  }

144 145
  bool get _closed => _closeMemo.hasRun;

146
  /// Uri of the test package.
147
  Uri get testUri => _flutterToolPackageConfig['test'].packageUriRoot;
148

149 150 151 152 153 154 155 156 157 158 159 160
  WebRendererMode get _rendererMode  {
    return buildInfo.dartDefines.contains('FLUTTER_WEB_USE_SKIA=true')
      ? WebRendererMode.canvaskit
      : WebRendererMode.html;
  }

  NullSafetyMode get _nullSafetyMode {
    return buildInfo.nullSafetyMode == NullSafetyMode.sound
      ? NullSafetyMode.sound
      : NullSafetyMode.unsound;
  }

161 162 163 164 165
  final Configuration _config;
  final shelf.Server _server;
  Uri get url => _server.url;

  /// The ahem text file.
166 167 168 169 170 171 172
  File get _ahem => _fileSystem.file(_fileSystem.path.join(
    Cache.flutterRoot,
    'packages',
    'flutter_tools',
    'static',
    'Ahem.ttf',
  ));
173 174

  /// The require js binary.
175 176 177 178 179 180 181 182
  File get _requireJs => _fileSystem.file(_fileSystem.path.join(
    _artifacts.getArtifactPath(Artifact.engineDartSdkPath),
    'lib',
    'dev_compiler',
    'kernel',
    'amd',
    'require.js',
  ));
183 184

  /// The ddc to dart stack trace mapper.
185 186 187 188 189 190 191
  File get _stackTraceMapper => _fileSystem.file(_fileSystem.path.join(
    _artifacts.getArtifactPath(Artifact.engineDartSdkPath),
    'lib',
    'dev_compiler',
    'web',
    'dart_stack_trace_mapper.js',
  ));
192

193 194
  File get _dartSdk => _fileSystem.file(
    _artifacts.getArtifactPath(kDartSdkJsArtifactMap[_rendererMode][_nullSafetyMode]));
195

196 197
  File get _dartSdkSourcemaps => _fileSystem.file(
    _artifacts.getArtifactPath(kDartSdkJsMapArtifactMap[_rendererMode][_nullSafetyMode]));
198 199

  /// The precompiled test javascript.
200 201
  File get _testDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
202 203 204
    'dart.js',
  ));

205 206
  File get _testHostDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
207 208 209 210 211 212
    'src',
    'runner',
    'browser',
    'static',
    'host.dart.js',
  ));
213

214 215 216
  Future<shelf.Response> _handleTestRequest(shelf.Request request) async {
    if (request.url.path.endsWith('.dart.browser_test.dart.js')) {
      final String leadingPath = request.url.path.split('.browser_test.dart.js')[0];
217
      final String generatedFile = _fileSystem.path.split(leadingPath).join('_') + '.bootstrap.js';
218
      return shelf.Response.ok(generateTestBootstrapFileContents('/' + generatedFile, 'require.js', 'dart_stack_trace_mapper.js'), headers: <String, String>{
219 220 221 222 223
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.bootstrap.js')) {
      final String leadingPath = request.url.path.split('.dart.bootstrap.js')[0];
224
      final String generatedFile = _fileSystem.path.split(leadingPath).join('_') + '.dart.test.dart.js';
225 226
      return shelf.Response.ok(generateMainModule(
        nullAssertions: nullAssertions,
227
        nativeNullAssertions: true,
228 229 230
        bootstrapModule: _fileSystem.path.basename(leadingPath) + '.dart.bootstrap',
        entrypoint: '/' + generatedFile
       ), headers: <String, String>{
231 232 233 234 235
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.js')) {
      final String path = request.url.path.split('.dart.js')[0];
236
      return shelf.Response.ok(webMemoryFS.files[path + '.dart.lib.js'], headers: <String, String>{
237 238 239 240
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.lib.js.map')) {
241
      return shelf.Response.ok(webMemoryFS.sourcemaps[request.url.path], headers: <String, String>{
242 243 244 245 246 247
        HttpHeaders.contentTypeHeader: 'text/plain',
      });
    }
    return shelf.Response.notFound('');
  }

248 249 250
  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
    if (request.requestedUri.path.contains('require.js')) {
      return shelf.Response.ok(
251
        _requireJs.openRead(),
252 253
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
254
    } else if (request.requestedUri.path.contains('ahem.ttf')) {
255
      return shelf.Response.ok(_ahem.openRead());
256 257
    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
      return shelf.Response.ok(
258
        _dartSdk.openRead(),
259 260
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
261 262 263 264 265
    } else if (request.requestedUri.path.contains('dart_sdk.js.map')) {
      return shelf.Response.ok(
        _dartSdkSourcemaps.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
266
    } else if (request.requestedUri.path
267
        .contains('dart_stack_trace_mapper.js')) {
268
      return shelf.Response.ok(
269
        _stackTraceMapper.openRead(),
270 271 272 273
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('static/dart.js')) {
      return shelf.Response.ok(
274
        _testDartJs.openRead(),
275 276 277 278
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('host.dart.js')) {
      return shelf.Response.ok(
279
        _testHostDartJs.openRead(),
280 281 282 283 284 285 286
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

287 288
  FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
    if (request.requestedUri.pathSegments.first == 'packages') {
289
      final Uri fileUri = buildInfo.packageConfig.resolve(Uri(
290 291 292
        scheme: 'package',
        pathSegments: request.requestedUri.pathSegments.skip(1),
      ));
293
      if (fileUri != null) {
294 295
        final String dirname = _fileSystem.path.dirname(fileUri.toFilePath());
        final String basename = _fileSystem.path.basename(fileUri.toFilePath());
296 297 298 299 300 301 302 303 304 305 306 307 308
        final shelf.Handler handler = createStaticHandler(dirname);
        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);
      }
309 310 311 312
    }
    return shelf.Response.notFound('Not Found');
  }

313 314 315 316 317 318 319 320 321 322
  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;

      try {
323 324
        final ChromeTab chromeTab = await _browserManager._browser.chromeConnection.getTab((ChromeTab tab) {
          return tab.url.contains(_browserManager._browser.url);
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
        });
        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) {
343
        _logger.printError('Caught WIPError: $ex');
344 345
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
346
        _logger.printError('Caught FormatException: $ex');
347 348 349 350 351 352 353 354 355 356 357 358 359 360
        return shelf.Response.ok('Caught exception: $ex');
      }

      if (bytes == null) {
        return shelf.Response.ok('Unknown error, bytes is null');
      }

      final String errorMessage = await _testGoldenComparator.compareGoldens(testUri, bytes, goldenKey, updateGoldens);
      return shelf.Response.ok(errorMessage ?? 'true');
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

361 362
  // A handler that serves wrapper files used to bootstrap tests.
  shelf.Response _wrapperHandler(shelf.Request request) {
363
    final String path = _fileSystem.path.fromUri(request.url);
364
    if (path.endsWith('.html')) {
365 366
      final String test = _fileSystem.path.withoutExtension(path) + '.dart';
      final String scriptBase = htmlEscape.convert(_fileSystem.path.basename(test));
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382
      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'});
    }
    return shelf.Response.notFound('Not found.');
  }

  @override
383 384 385 386 387 388
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Object message,
  ) async {
389
    if (_closed) {
390
      throw StateError('Load called on a closed FlutterWebPlatform');
391
    }
392 393
    final PoolResource lockResource = await _suiteLock.request();

394
    final Runtime browser = platform.runtime;
395 396 397 398 399 400 401 402
    try {
      _browserManager = await _launchBrowser(browser);
    } on Error catch (_) {
      await _suiteLock.close();
      rethrow;
    }

    if (_closed) {
403
      throw StateError('Load called on a closed FlutterWebPlatform');
404 405
    }

406 407 408
    final Uri suiteUrl = url.resolveUri(_fileSystem.path.toUri(_fileSystem.path.withoutExtension(
        _fileSystem.path.relative(path, from: _fileSystem.path.join(_root, 'test'))) + '.html'));
    final String relativePath = _fileSystem.path.relative(_fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path);
409
    final RunnerSuite suite = await _browserManager.load(relativePath, suiteUrl, suiteConfig, message, onDone: () async {
410 411 412 413
      await _browserManager.close();
      _browserManager = null;
      lockResource.release();
    });
414
    if (_closed) {
415
      throw StateError('Load called on a closed FlutterWebPlatform');
416 417 418 419 420 421 422 423 424 425 426
    }
    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.
427 428 429
  Future<BrowserManager> _launchBrowser(Runtime browser) {
    if (_browserManager != null) {
      throw StateError('Another browser is currently running.');
430
    }
431

432 433
    final Completer<WebSocketChannel> completer = Completer<WebSocketChannel>.sync();
    final String path = _webSocketHandler.create(webSocketHandler(completer.complete));
434 435
    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
    final Uri hostUrl = url
436 437 438 439 440
      .resolve('static/index.html')
      .replace(queryParameters: <String, String>{
        'managerUrl': webSocketUrl.toString(),
        'debug': _config.pauseAfterLoad.toString(),
      });
441

442
    _logger.printTrace('Serving tests at $hostUrl');
443

444
    return BrowserManager.start(
445
      _chromiumLauncher,
446 447 448
      browser,
      hostUrl,
      completer.future,
449
      headless: !_config.pauseAfterLoad,
450 451 452 453
    );
  }

  @override
454 455 456 457
  Future<void> closeEphemeral() async {
    if (_browserManager != null) {
      await _browserManager.close();
    }
458 459 460 461
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
462 463 464 465 466 467
    await Future.wait<void>(<Future<dynamic>>[
      if (_browserManager != null)
        _browserManager.close(),
      _server.close(),
      _testGoldenComparator.close(),
    ]);
468
  });
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495
}

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) {
496
    final List<String> components = request.url.path.split('/');
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520
    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), () {
521
      for (final RunnerSuiteController controller in _controllers) {
522 523 524 525 526
        controller.setDebugging(true);
      }
    })
      ..cancel();

527 528
    // 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.
529
    _channel = MultiChannel<dynamic>(
530 531 532 533 534
      webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
        return stream.map((Object message) {
          if (!_closed) {
            _timer.reset();
          }
535
          for (final RunnerSuiteController controller in _controllers) {
536 537
            controller.setDebugging(false);
          }
538

539 540 541 542
          return message;
        });
      }),
    );
543 544 545 546 547 548

    _environment = _loadBrowserEnvironment();
    _channel.stream.listen(_onMessage, onDone: close);
  }

  /// The browser instance that this is connected to via [_channel].
549
  final Chromium _browser;
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599
  final Runtime _runtime;

  /// The channel used to communicate with the browser.
  ///
  /// This is connected to a page running `static/host.dart`.
  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.
  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.
  ///
600 601
  /// The browser will start in headless mode if [headless] is true.
  ///
602 603 604 605 606
  /// 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(
607
    ChromiumLauncher chromiumLauncher,
608 609 610 611
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
612
    bool headless = true,
613
  }) async {
614
    final Chromium chrome = await chromiumLauncher.launch(url.toString(), headless: headless);
615 616
    final Completer<BrowserManager> completer = Completer<BrowserManager>();

617 618
    unawaited(chrome.onExit.then((int browserExitCode) {
      throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
619 620
    }).catchError((dynamic error, StackTrace stackTrace) {
      if (completer.isCompleted) {
621
        return null;
622 623 624 625 626 627 628 629 630 631 632
      }
      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) {
633
        return null;
634 635 636 637 638 639 640 641 642 643 644 645 646 647
      }
      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(
648
        this, null, _browser.chromeConnection.url, _onRestartController.stream);
649 650
  }

651
  /// Tells the browser to load a test suite from the URL [url].
652 653 654 655 656 657 658 659
  ///
  /// [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(
660 661 662
    String path,
    Uri url,
    SuiteConfiguration suiteConfig,
663 664 665
    Object message, {
      Future<void> Function() onDone,
    }
666
  ) async {
667
    url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
668
      'metadata': suiteConfig.metadata.serialize(),
669
      'browser': _runtime.identifier,
670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687
    })));

    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(
688 689 690
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
691
        onDone();
692 693
      }),
    );
694

695 696 697 698 699 700
    _channel.sink.add(<String, Object>{
      'command': 'loadSuite',
      'url': url.toString(),
      'id': suiteID,
      'channel': suiteChannelID,
    });
701

702 703 704
    try {
      controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
        suiteConfig, await _environment, suiteChannel, message);
705

706 707 708 709 710 711 712
      _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;
    }
713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771
  }

  /// 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 {
772 773 774 775 776 777
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );
778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795

  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();
}