flutter_web_platform.dart 29.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7
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
import 'package:pool/pool.dart';
15
import 'package:process/process.dart';
16 17 18 19 20
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';
21
import 'package:test_core/src/platform.dart'; // ignore: implementation_imports
22
import 'package:web_socket_channel/web_socket_channel.dart';
23
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;
24 25 26 27

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

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

90
  final WebMemoryFS webMemoryFS;
91
  final BuildInfo buildInfo;
92 93 94 95 96
  final FileSystem _fileSystem;
  final PackageConfig _flutterToolPackageConfig;
  final ChromiumLauncher _chromiumLauncher;
  final Logger _logger;
  final Artifacts _artifacts;
97 98 99 100 101
  final bool updateGoldens;
  final bool nullAssertions;
  final OneOffHandler _webSocketHandler = OneOffHandler();
  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
  final String _root;
102
  final Cache _cache;
103 104 105 106 107 108 109 110

  /// 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;
111

112 113 114 115
  static Future<FlutterWebPlatform> start(String root, {
    FlutterProject flutterProject,
    String shellPath,
    bool updateGoldens = false,
116
    bool pauseAfterLoad = false,
117
    bool nullAssertions = false,
118
    @required BuildInfo buildInfo,
119
    @required WebMemoryFS webMemoryFS,
120 121 122 123
    @required FileSystem fileSystem,
    @required Logger logger,
    @required ChromiumLauncher chromiumLauncher,
    @required Artifacts artifacts,
124
    @required ProcessManager processManager,
125
    @required Cache cache,
126
  }) async {
127
    final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
128 129 130 131 132 133 134 135 136 137
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
      fileSystem.file(fileSystem.path.join(
        Cache.flutterRoot,
        'packages',
        'flutter_tools',
        '.dart_tool',
        'package_config.json',
      )),
      logger: logger,
    );
138 139
    return FlutterWebPlatform._(
      server,
140
      Configuration.current.change(pauseAfterLoad: pauseAfterLoad),
141
      root,
142 143 144
      flutterProject: flutterProject,
      shellPath: shellPath,
      updateGoldens: updateGoldens,
145
      buildInfo: buildInfo,
146
      webMemoryFS: webMemoryFS,
147 148 149 150 151
      flutterToolPackageConfig: packageConfig,
      fileSystem: fileSystem,
      chromiumLauncher: chromiumLauncher,
      artifacts: artifacts,
      logger: logger,
152
      nullAssertions: nullAssertions,
153
      processManager: processManager,
154
      cache: cache,
155 156 157
    );
  }

158 159
  bool get _closed => _closeMemo.hasRun;

160
  /// Uri of the test package.
161
  Uri get testUri => _flutterToolPackageConfig['test'].packageUriRoot;
162

163 164 165 166 167 168 169 170 171 172 173 174
  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;
  }

175 176 177 178 179
  final Configuration _config;
  final shelf.Server _server;
  Uri get url => _server.url;

  /// The ahem text file.
180 181 182 183 184 185 186
  File get _ahem => _fileSystem.file(_fileSystem.path.join(
    Cache.flutterRoot,
    'packages',
    'flutter_tools',
    'static',
    'Ahem.ttf',
  ));
187 188

  /// The require js binary.
189
  File get _requireJs => _fileSystem.file(_fileSystem.path.join(
190
    _artifacts.getHostArtifact(HostArtifact.engineDartSdkPath).path,
191 192 193 194 195 196
    'lib',
    'dev_compiler',
    'kernel',
    'amd',
    'require.js',
  ));
197 198

  /// The ddc to dart stack trace mapper.
199
  File get _stackTraceMapper => _fileSystem.file(_fileSystem.path.join(
200
    _artifacts.getHostArtifact(HostArtifact.engineDartSdkPath).path,
201 202 203 204 205
    'lib',
    'dev_compiler',
    'web',
    'dart_stack_trace_mapper.js',
  ));
206

207
  File get _dartSdk => _fileSystem.file(
208
    _artifacts.getHostArtifact(kDartSdkJsArtifactMap[_rendererMode][_nullSafetyMode]));
209

210
  File get _dartSdkSourcemaps => _fileSystem.file(
211
    _artifacts.getHostArtifact(kDartSdkJsMapArtifactMap[_rendererMode][_nullSafetyMode]));
212 213

  /// The precompiled test javascript.
214 215
  File get _testDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
216 217 218
    'dart.js',
  ));

219 220
  File get _testHostDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
221 222 223 224 225 226
    'src',
    'runner',
    'browser',
    'static',
    'host.dart.js',
  ));
227

228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
  File _canvasKitFile(String relativePath) {
    // TODO(yjbanov): https://github.com/flutter/flutter/issues/52588
    //
    // Update this when we start building CanvasKit from sources. In the
    // meantime, get the Web SDK directory from cache rather than through
    // Artifacts. The latter is sensitive to `--local-engine`, which changes
    // the directory to point to ENGINE/src/out. However, CanvasKit is not yet
    // built as part of the engine, but fetched from CIPD, and so it won't be
    // found in ENGINE/src/out.
    final Directory webSdkDirectory = _cache.getWebSdkDirectory();
    final File canvasKitFile = _fileSystem.file(_fileSystem.path.join(
      webSdkDirectory.path,
      relativePath,
    ));
    return canvasKitFile;
  }

245 246 247
  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];
248 249
      final String generatedFile = '${_fileSystem.path.split(leadingPath).join('_')}.bootstrap.js';
      return shelf.Response.ok(generateTestBootstrapFileContents('/$generatedFile', 'require.js', 'dart_stack_trace_mapper.js'), headers: <String, String>{
250 251 252 253 254
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.bootstrap.js')) {
      final String leadingPath = request.url.path.split('.dart.bootstrap.js')[0];
255
      final String generatedFile = '${_fileSystem.path.split(leadingPath).join('_')}.dart.test.dart.js';
256 257
      return shelf.Response.ok(generateMainModule(
        nullAssertions: nullAssertions,
258
        nativeNullAssertions: true,
259 260
        bootstrapModule: '${_fileSystem.path.basename(leadingPath)}.dart.bootstrap',
        entrypoint: '/$generatedFile'
261
       ), headers: <String, String>{
262 263 264 265 266
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.js')) {
      final String path = request.url.path.split('.dart.js')[0];
267
      return shelf.Response.ok(webMemoryFS.files['$path.dart.lib.js'], headers: <String, String>{
268 269 270 271
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.lib.js.map')) {
272
      return shelf.Response.ok(webMemoryFS.sourcemaps[request.url.path], headers: <String, String>{
273 274 275 276 277 278
        HttpHeaders.contentTypeHeader: 'text/plain',
      });
    }
    return shelf.Response.notFound('');
  }

279 280 281
  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
    if (request.requestedUri.path.contains('require.js')) {
      return shelf.Response.ok(
282
        _requireJs.openRead(),
283 284
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
285
    } else if (request.requestedUri.path.contains('ahem.ttf')) {
286
      return shelf.Response.ok(_ahem.openRead());
287 288
    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
      return shelf.Response.ok(
289
        _dartSdk.openRead(),
290 291
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
292 293 294 295 296
    } else if (request.requestedUri.path.contains('dart_sdk.js.map')) {
      return shelf.Response.ok(
        _dartSdkSourcemaps.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
297
    } else if (request.requestedUri.path
298
        .contains('dart_stack_trace_mapper.js')) {
299
      return shelf.Response.ok(
300
        _stackTraceMapper.openRead(),
301 302 303 304
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('static/dart.js')) {
      return shelf.Response.ok(
305
        _testDartJs.openRead(),
306 307 308 309
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('host.dart.js')) {
      return shelf.Response.ok(
310
        _testHostDartJs.openRead(),
311 312 313 314 315 316 317
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

318 319
  FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
    if (request.requestedUri.pathSegments.first == 'packages') {
320
      final Uri fileUri = buildInfo.packageConfig.resolve(Uri(
321 322 323
        scheme: 'package',
        pathSegments: request.requestedUri.pathSegments.skip(1),
      ));
324
      if (fileUri != null) {
325 326
        final String dirname = _fileSystem.path.dirname(fileUri.toFilePath());
        final String basename = _fileSystem.path.basename(fileUri.toFilePath());
327 328 329 330 331 332 333 334 335 336 337 338 339
        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);
      }
340 341 342 343
    }
    return shelf.Response.notFound('Not Found');
  }

344 345 346 347 348 349 350 351 352 353
  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 {
354 355
        final ChromeTab chromeTab = await _browserManager._browser.chromeConnection.getTab((ChromeTab tab) {
          return tab.url.contains(_browserManager._browser.url);
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
        });
        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) {
374
        _logger.printError('Caught WIPError: $ex');
375 376
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
377
        _logger.printError('Caught FormatException: $ex');
378 379 380 381 382 383 384 385 386 387 388 389 390 391
        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');
    }
  }

392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422
  /// Serves a local build of CanvasKit, replacing the CDN build, which can
  /// cause test flakiness due to reliance on network.
  shelf.Response _localCanvasKitHandler(shelf.Request request) {
    final String path = _fileSystem.path.fromUri(request.url);
    if (!path.startsWith('canvaskit/')) {
      return shelf.Response.notFound('Not a CanvasKit file request');
    }

    final String extension = _fileSystem.path.extension(path);
    String contentType;
    switch (extension) {
      case '.js':
        contentType = 'text/javascript';
        break;
      case '.wasm':
        contentType = 'application/wasm';
        break;
      default:
        final String error = 'Failed to determine Content-Type for "${request.url.path}".';
        _logger.printError(error);
        return shelf.Response.internalServerError(body: error);
    }

    return shelf.Response.ok(
      _canvasKitFile(path).openRead(),
      headers: <String, Object>{
        HttpHeaders.contentTypeHeader: contentType,
      },
    );
  }

423 424
  // A handler that serves wrapper files used to bootstrap tests.
  shelf.Response _wrapperHandler(shelf.Request request) {
425
    final String path = _fileSystem.path.fromUri(request.url);
426
    if (path.endsWith('.html')) {
427
      final String test = '${_fileSystem.path.withoutExtension(path)}.dart';
428
      final String scriptBase = htmlEscape.convert(_fileSystem.path.basename(test));
429 430 431 432 433 434
      final String link = '<link rel="x-dart-test" href="$scriptBase">';
      return shelf.Response.ok('''
        <!DOCTYPE html>
        <html>
        <head>
          <title>${htmlEscape.convert(test)} Test</title>
435 436 437 438 439
          <script>
            window.flutterConfiguration = {
              canvasKitBaseUrl: "/canvaskit/"
            };
          </script>
440 441 442 443 444 445 446 447 448 449
          $link
          <script src="static/dart.js"></script>
        </head>
        </html>
      ''', headers: <String, String>{'Content-Type': 'text/html'});
    }
    return shelf.Response.notFound('Not found.');
  }

  @override
450 451 452 453 454 455
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Object message,
  ) async {
456
    if (_closed) {
457
      throw StateError('Load called on a closed FlutterWebPlatform');
458
    }
459 460
    final PoolResource lockResource = await _suiteLock.request();

461
    final Runtime browser = platform.runtime;
462 463 464 465 466 467 468 469
    try {
      _browserManager = await _launchBrowser(browser);
    } on Error catch (_) {
      await _suiteLock.close();
      rethrow;
    }

    if (_closed) {
470
      throw StateError('Load called on a closed FlutterWebPlatform');
471 472
    }

473 474
    final String pathFromTest = _fileSystem.path.relative(path, from: _fileSystem.path.join(_root, 'test'));
    final Uri suiteUrl = url.resolveUri(_fileSystem.path.toUri('${_fileSystem.path.withoutExtension(pathFromTest)}.html'));
475
    final String relativePath = _fileSystem.path.relative(_fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path);
476
    final RunnerSuite suite = await _browserManager.load(relativePath, suiteUrl, suiteConfig, message, onDone: () async {
477 478 479 480
      await _browserManager.close();
      _browserManager = null;
      lockResource.release();
    });
481
    if (_closed) {
482
      throw StateError('Load called on a closed FlutterWebPlatform');
483 484 485 486 487 488 489
    }
    return suite;
  }

  /// Returns the [BrowserManager] for [runtime], which should be a browser.
  ///
  /// If no browser manager is running yet, starts one.
490 491 492
  Future<BrowserManager> _launchBrowser(Runtime browser) {
    if (_browserManager != null) {
      throw StateError('Another browser is currently running.');
493
    }
494

495 496
    final Completer<WebSocketChannel> completer = Completer<WebSocketChannel>.sync();
    final String path = _webSocketHandler.create(webSocketHandler(completer.complete));
497 498
    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
    final Uri hostUrl = url
499 500 501 502 503
      .resolve('static/index.html')
      .replace(queryParameters: <String, String>{
        'managerUrl': webSocketUrl.toString(),
        'debug': _config.pauseAfterLoad.toString(),
      });
504

505
    _logger.printTrace('Serving tests at $hostUrl');
506

507
    return BrowserManager.start(
508
      _chromiumLauncher,
509 510 511
      browser,
      hostUrl,
      completer.future,
512
      headless: !_config.pauseAfterLoad,
513 514 515 516
    );
  }

  @override
517 518 519 520
  Future<void> closeEphemeral() async {
    if (_browserManager != null) {
      await _browserManager.close();
    }
521 522 523 524
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
525 526 527 528 529 530
    await Future.wait<void>(<Future<dynamic>>[
      if (_browserManager != null)
        _browserManager.close(),
      _server.close(),
      _testGoldenComparator.close(),
    ]);
531
  });
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558
}

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) {
559
    final List<String> components = request.url.path.split('/');
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583
    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), () {
584
      for (final RunnerSuiteController controller in _controllers) {
585 586 587 588 589
        controller.setDebugging(true);
      }
    })
      ..cancel();

590 591
    // 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.
592
    _channel = MultiChannel<dynamic>(
593 594 595 596 597
      webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
        return stream.map((Object message) {
          if (!_closed) {
            _timer.reset();
          }
598
          for (final RunnerSuiteController controller in _controllers) {
599 600
            controller.setDebugging(false);
          }
601

602 603 604 605
          return message;
        });
      }),
    );
606 607 608 609 610 611

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

  /// The browser instance that this is connected to via [_channel].
612
  final Chromium _browser;
613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662
  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.
  ///
663 664
  /// The browser will start in headless mode if [headless] is true.
  ///
665 666 667 668 669
  /// 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(
670
    ChromiumLauncher chromiumLauncher,
671 672 673 674
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
675
    bool headless = true,
676
  }) async {
677
    final Chromium chrome = await chromiumLauncher.launch(url.toString(), headless: headless);
678 679
    final Completer<BrowserManager> completer = Completer<BrowserManager>();

680 681
    unawaited(chrome.onExit.then((int browserExitCode) {
      throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
682 683
    }).catchError((dynamic error, StackTrace stackTrace) {
      if (completer.isCompleted) {
684
        return null;
685 686 687 688 689 690 691 692 693 694 695
      }
      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) {
696
        return null;
697 698 699 700 701 702 703 704 705 706 707 708 709
      }
      completer.completeError(error, stackTrace);
    }));

    return completer.future.timeout(const Duration(seconds: 30), onTimeout: () {
      chrome.close();
      throwToolExit('Timed out waiting for ${runtime.name} to connect.');
    });
  }

  /// Loads [_BrowserEnvironment].
  Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
    return _BrowserEnvironment(
710
        this, null, _browser.chromeConnection.url, _onRestartController.stream);
711 712
  }

713
  /// Tells the browser to load a test suite from the URL [url].
714 715 716 717 718 719 720 721
  ///
  /// [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(
722 723 724
    String path,
    Uri url,
    SuiteConfiguration suiteConfig,
725 726 727
    Object message, {
      Future<void> Function() onDone,
    }
728
  ) async {
729
    url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
730
      'metadata': suiteConfig.metadata.serialize(),
731
      'browser': _runtime.identifier,
732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749
    })));

    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(
750 751 752
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
753
        onDone();
754 755
      }),
    );
756

757 758 759 760 761 762
    _channel.sink.add(<String, Object>{
      'command': 'loadSuite',
      'url': url.toString(),
      'id': suiteID,
      'channel': suiteChannelID,
    });
763

764 765 766
    try {
      controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
        suiteConfig, await _environment, suiteChannel, message);
767

768 769 770 771 772 773 774
      _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;
    }
775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795
  }

  /// 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) {
796 797 798 799 800 801 802 803 804 805 806 807 808 809
    assert(message is Map<String, dynamic>);
    if (message is Map<String, dynamic>) {
      switch (message['command'] as String) {
        case 'ping':
          break;
        case 'restart':
          _onRestartController.add(null);
          break;
        case 'resume':
          if (_pauseCompleter != null) {
            _pauseCompleter.complete();
          }
          break;
        default:
810
        // Unreachable.
811 812 813
          assert(false);
          break;
      }
814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836
    }
  }

  /// 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 {
837 838 839 840 841 842
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );
843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860

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