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

import 'dart:async';
6
import 'dart:typed_data';
7 8 9

import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
10
import 'package:package_config/package_config.dart';
11
import 'package:pool/pool.dart';
12
import 'package:process/process.dart';
13 14 15 16 17
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';
18
import 'package:test_core/src/platform.dart'; // ignore: implementation_imports
19
import 'package:web_socket_channel/web_socket_channel.dart';
20
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;
21 22 23 24

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

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

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

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

108 109
  BrowserManager? _browserManager;
  late TestGoldenComparator _testGoldenComparator;
110

111
  static Future<FlutterWebPlatform> start(String root, {
112 113
    FlutterProject? flutterProject,
    String? shellPath,
114
    bool updateGoldens = false,
115
    bool pauseAfterLoad = false,
116
    bool nullAssertions = false,
117 118 119 120 121 122 123 124
    required BuildInfo buildInfo,
    required WebMemoryFS webMemoryFS,
    required FileSystem fileSystem,
    required Logger logger,
    required ChromiumLauncher chromiumLauncher,
    required Artifacts? artifacts,
    required ProcessManager processManager,
    required Cache cache,
125
    TestTimeRecorder? testTimeRecorder,
126
  }) async {
127
    final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
128 129
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
      fileSystem.file(fileSystem.path.join(
130
        Cache.flutterRoot!,
131 132 133 134 135 136 137
        '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
      testTimeRecorder: testTimeRecorder,
156 157 158
    );
  }

159 160
  bool get _closed => _closeMemo.hasRun;

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

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

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

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

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

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

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

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

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

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

229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
  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;
  }

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

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

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

345 346 347
  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>;
348 349 350 351
      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;
352 353 354
      Uint8List bytes;

      try {
355 356 357
        final ChromeTab chromeTab = await (_browserManager!._browser.chromeConnection.getTab((ChromeTab tab) {
          return tab.url.contains(_browserManager!._browser.url!);
        }) as FutureOr<ChromeTab>);
358 359 360 361 362 363 364 365 366 367 368 369 370
        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,
371
          },
372
        });
373
        bytes = base64.decode(response.result!['data'] as String);
374
      } on WipError catch (ex) {
375
        _logger.printError('Caught WIPError: $ex');
376 377
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
378
        _logger.printError('Caught FormatException: $ex');
379 380 381 382 383 384 385
        return shelf.Response.ok('Caught exception: $ex');
      }

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

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

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 423
  /// 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,
      },
    );
  }

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

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

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

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

474 475
    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'));
476
    final String relativePath = _fileSystem.path.relative(_fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path);
477 478
    final RunnerSuite suite = await _browserManager!.load(relativePath, suiteUrl, suiteConfig, message, onDone: () async {
      await _browserManager!.close();
479 480 481
      _browserManager = null;
      lockResource.release();
    });
482
    if (_closed) {
483
      throw StateError('Load called on a closed FlutterWebPlatform');
484 485 486 487 488 489 490
    }
    return suite;
  }

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

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

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

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

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

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
526 527
    await Future.wait<void>(<Future<dynamic>>[
      if (_browserManager != null)
528
        _browserManager!.close(),
529 530 531
      _server.close(),
      _testGoldenComparator.close(),
    ]);
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 559
}

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) {
560
    final List<String> components = request.url.path.split('/');
561 562 563 564
    if (components.isEmpty) {
      return shelf.Response.notFound(null);
    }
    final String path = components.removeAt(0);
565
    final FutureOr<shelf.Response> Function(shelf.Request)? handler =
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584
        _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), () {
585
      for (final RunnerSuiteController controller in _controllers) {
586 587 588 589 590
        controller.setDebugging(true);
      }
    })
      ..cancel();

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

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

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

  /// The browser instance that this is connected to via [_channel].
613
  final Chromium _browser;
614 615 616 617 618
  final Runtime _runtime;

  /// The channel used to communicate with the browser.
  ///
  /// This is connected to a page running `static/host.dart`.
619
  late MultiChannel<dynamic> _channel;
620 621 622 623 624 625 626 627 628 629 630 631 632 633

  /// 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.
634
  CancelableCompleter<dynamic>? _pauseCompleter;
635 636 637 638 639 640

  /// The controller for [_BrowserEnvironment.onRestart].
  final StreamController<dynamic> _onRestartController =
      StreamController<dynamic>.broadcast();

  /// The environment to attach to each suite.
641
  late Future<_BrowserEnvironment> _environment;
642 643 644 645 646 647 648 649 650 651 652

  /// 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.
653
  late RestartableTimer _timer;
654 655 656 657 658 659 660 661 662 663

  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.
  ///
664 665
  /// The browser will start in headless mode if [headless] is true.
  ///
666 667
  /// Add arbitrary browser flags via [webBrowserFlags].
  ///
668 669 670 671 672
  /// 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(
673
    ChromiumLauncher chromiumLauncher,
674 675 676 677
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
678
    bool headless = true,
679
    List<String> webBrowserFlags = const <String>[],
680
  }) async {
681 682 683 684 685
    final Chromium chrome = await chromiumLauncher.launch(
      url.toString(),
      headless: headless,
      webBrowserFlags: webBrowserFlags,
    );
686 687
    final Completer<BrowserManager> completer = Completer<BrowserManager>();

688
    unawaited(chrome.onExit.then((int? browserExitCode) {
689
      throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
690 691 692 693 694
    })
    // TODO(srawlins): Fix this static issue,
    // https://github.com/flutter/flutter/issues/105750.
    // ignore: body_might_complete_normally_catch_error
    .catchError((Object error, StackTrace stackTrace) {
695 696
      if (!completer.isCompleted) {
        completer.completeError(error, stackTrace);
697 698 699 700 701 702 703
      }
    }));
    unawaited(future.then((WebSocketChannel webSocket) {
      if (completer.isCompleted) {
        return;
      }
      completer.complete(BrowserManager._(chrome, runtime, webSocket));
704
    }).catchError((Object error, StackTrace stackTrace) {
705
      chrome.close();
706 707
      if (!completer.isCompleted) {
        completer.completeError(error, stackTrace);
708 709 710
      }
    }));

711
    return completer.future;
712 713 714 715 716
  }

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

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

    final int suiteID = _suiteID++;
742
    RunnerSuiteController? controller;
743 744 745 746 747 748 749 750 751 752 753 754 755 756
    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(
757 758 759
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
760
        onDone!();
761 762
      }),
    );
763

764 765 766 767 768 769
    _channel.sink.add(<String, Object>{
      'command': 'loadSuite',
      'url': url.toString(),
      'id': suiteID,
      'channel': suiteChannelID,
    });
770

771 772 773
    try {
      controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
        suiteConfig, await _environment, suiteChannel, message);
774

775 776 777 778 779 780 781
      _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;
    }
782 783 784 785 786
  }

  /// An implementation of [Environment.displayPause].
  CancelableOperation<dynamic> _displayPause() {
    if (_pauseCompleter != null) {
787
      return _pauseCompleter!.operation;
788 789 790 791 792
    }
    _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
      _channel.sink.add(<String, String>{'command': 'resume'});
      _pauseCompleter = null;
    });
793
    _pauseCompleter!.operation.value.whenComplete(() {
794 795 796 797
      _pauseCompleter = null;
    });
    _channel.sink.add(<String, String>{'command': 'displayPause'});

798
    return _pauseCompleter!.operation;
799 800 801 802
  }

  /// The callback for handling messages received from the host page.
  void _onMessage(dynamic message) {
803 804
    assert(message is Map<String, dynamic>);
    if (message is Map<String, dynamic>) {
805
      switch (message['command'] as String?) {
806 807 808 809 810 811 812
        case 'ping':
          break;
        case 'restart':
          _onRestartController.add(null);
          break;
        case 'resume':
          if (_pauseCompleter != null) {
813
            _pauseCompleter!.complete();
814 815 816
          }
          break;
        default:
817
        // Unreachable.
818 819 820
          assert(false);
          break;
      }
821 822 823 824 825 826 827 828 829 830
    }
  }

  /// 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) {
831
        _pauseCompleter!.complete();
832 833 834 835 836 837 838 839 840 841 842 843
      }
      _pauseCompleter = null;
      _controllers.clear();
      return _browser.close();
    });
  }
}

/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
844 845 846 847 848 849
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );
850 851 852 853 854 855 856

  final BrowserManager _manager;

  @override
  final bool supportsDebugging = true;

  @override
857
  final Uri? observatoryUrl;
858 859 860 861 862 863 864 865 866 867

  @override
  final Uri remoteDebuggerUrl;

  @override
  final Stream<dynamic> onRestart;

  @override
  CancelableOperation<dynamic> displayPause() => _manager._displayPause();
}