flutter_web_platform.dart 29 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
    required this.buildInfo,
    required this.webMemoryFS,
    required FileSystem fileSystem,
    required PackageConfig flutterToolPackageConfig,
    required ChromiumLauncher chromiumLauncher,
    required Logger logger,
    required Artifacts? artifacts,
    required ProcessManager processManager,
54
    TestTimeRecorder? testTimeRecorder,
55 56 57 58
  }) : _fileSystem = fileSystem,
      _flutterToolPackageConfig = flutterToolPackageConfig,
      _chromiumLauncher = chromiumLauncher,
      _logger = logger,
59
      _artifacts = artifacts {
60 61 62
    final shelf.Cascade cascade = shelf.Cascade()
        .add(_webSocketHandler.handler)
        .add(createStaticHandler(
63
          fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools'),
64 65 66
          serveFilesOutsidePath: true,
        ))
        .add(_handleStaticArtifact)
67
        .add(_localCanvasKitHandler)
68
        .add(_goldenFileHandler)
69
        .add(_wrapperHandler)
70
        .add(_handleTestRequest)
71
        .add(createStaticHandler(
72
          fileSystem.path.join(fileSystem.currentDirectory.path, 'test'),
73 74 75
          serveFilesOutsidePath: true,
        ))
        .add(_packageFilesHandler);
76
    _server.mount(cascade.handler);
77 78
    _testGoldenComparator = TestGoldenComparator(
      shellPath,
79
      () => TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
80 81 82
      fileSystem: _fileSystem,
      logger: _logger,
      processManager: processManager,
83
      webRenderer: _rendererMode,
84
    );
85 86
  }

87
  final WebMemoryFS webMemoryFS;
88
  final BuildInfo buildInfo;
89 90 91 92
  final FileSystem _fileSystem;
  final PackageConfig _flutterToolPackageConfig;
  final ChromiumLauncher _chromiumLauncher;
  final Logger _logger;
93 94
  final Artifacts? _artifacts;
  final bool? updateGoldens;
95
  final bool? nullAssertions;
96 97 98 99 100 101 102 103 104
  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);

105 106
  BrowserManager? _browserManager;
  late TestGoldenComparator _testGoldenComparator;
107

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

154 155
  bool get _closed => _closeMemo.hasRun;

156
  /// Uri of the test package.
157
  Uri get testUri => _flutterToolPackageConfig['test']!.packageUriRoot;
158

159 160 161 162 163 164
  WebRendererMode get _rendererMode  {
    return buildInfo.dartDefines.contains('FLUTTER_WEB_USE_SKIA=true')
      ? WebRendererMode.canvaskit
      : WebRendererMode.html;
  }

165 166 167 168 169 170
  NullSafetyMode get _nullSafetyMode {
    return buildInfo.nullSafetyMode == NullSafetyMode.sound
      ? NullSafetyMode.sound
      : NullSafetyMode.unsound;
  }

171 172 173 174 175
  final Configuration _config;
  final shelf.Server _server;
  Uri get url => _server.url;

  /// The ahem text file.
176
  File get _ahem => _fileSystem.file(_fileSystem.path.join(
177
    Cache.flutterRoot!,
178 179 180 181 182
    'packages',
    'flutter_tools',
    'static',
    'Ahem.ttf',
  ));
183 184

  /// The require js binary.
185 186 187 188 189 190 191
  File get _requireJs => _fileSystem.file(_fileSystem.path.join(
        _artifacts!.getArtifactPath(Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript),
        'lib',
        'dev_compiler',
        'amd',
        'require.js',
      ));
192 193

  /// The ddc to dart stack trace mapper.
194
  File get _stackTraceMapper => _fileSystem.file(_fileSystem.path.join(
195
    _artifacts!.getArtifactPath(Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript),
196 197 198 199 200
    'lib',
    'dev_compiler',
    'web',
    'dart_stack_trace_mapper.js',
  ));
201

202
  File get _dartSdk => _fileSystem.file(
203
    _artifacts!.getHostArtifact(kDartSdkJsArtifactMap[_rendererMode]![_nullSafetyMode]!));
204

205
  File get _dartSdkSourcemaps => _fileSystem.file(
206
    _artifacts!.getHostArtifact(kDartSdkJsMapArtifactMap[_rendererMode]![_nullSafetyMode]!));
207 208

  /// The precompiled test javascript.
209 210
  File get _testDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
211 212 213
    'dart.js',
  ));

214 215
  File get _testHostDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
216 217 218 219 220 221
    'src',
    'runner',
    'browser',
    'static',
    'host.dart.js',
  ));
222

223
  File _canvasKitFile(String relativePath) {
224 225 226
    final String canvasKitPath = _fileSystem.path.join(
      _artifacts!.getHostArtifact(HostArtifact.flutterWebSdk).path,
      'canvaskit',
227
    );
228
    final File canvasKitFile = _fileSystem.file(_fileSystem.path.join(
229
      canvasKitPath,
230 231 232 233 234
      relativePath,
    ));
    return canvasKitFile;
  }

235 236 237
  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];
238 239
      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>{
240 241 242 243 244
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.bootstrap.js')) {
      final String leadingPath = request.url.path.split('.dart.bootstrap.js')[0];
245
      final String generatedFile = '${_fileSystem.path.split(leadingPath).join('_')}.dart.test.dart.js';
246
      return shelf.Response.ok(generateMainModule(
247
        nullAssertions: nullAssertions!,
248
        nativeNullAssertions: true,
249 250
        bootstrapModule: '${_fileSystem.path.basename(leadingPath)}.dart.bootstrap',
        entrypoint: '/$generatedFile'
251
       ), headers: <String, String>{
252 253 254 255 256
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.js')) {
      final String path = request.url.path.split('.dart.js')[0];
257
      return shelf.Response.ok(webMemoryFS.files['$path.dart.lib.js'], headers: <String, String>{
258 259 260 261
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.lib.js.map')) {
262
      return shelf.Response.ok(webMemoryFS.sourcemaps[request.url.path], headers: <String, String>{
263 264 265 266 267 268
        HttpHeaders.contentTypeHeader: 'text/plain',
      });
    }
    return shelf.Response.notFound('');
  }

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

308 309
  FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
    if (request.requestedUri.pathSegments.first == 'packages') {
310
      final Uri? fileUri = buildInfo.packageConfig.resolve(Uri(
311 312 313
        scheme: 'package',
        pathSegments: request.requestedUri.pathSegments.skip(1),
      ));
314
      if (fileUri != null) {
315 316
        final String dirname = _fileSystem.path.dirname(fileUri.toFilePath());
        final String basename = _fileSystem.path.basename(fileUri.toFilePath());
317 318 319 320 321 322 323 324 325 326 327 328 329
        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);
      }
330 331 332 333
    }
    return shelf.Response.notFound('Not Found');
  }

334
  Future<shelf.Response> _goldenFileHandler(shelf.Request request) async {
335 336 337 338
    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);
339 340
      final num width = body['width']! as num;
      final num height = body['height']! as num;
341 342 343
      Uint8List bytes;

      try {
344
        final ChromeTab chromeTab = (await _browserManager!._browser.chromeConnection.getTab((ChromeTab tab) {
345
          return tab.url.contains(_browserManager!._browser.url!);
346
        }))!;
347 348 349 350 351 352 353 354 355 356 357 358 359
        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,
360
          },
361
        });
362
        bytes = base64.decode(response.result!['data'] as String);
363
      } on WipError catch (ex) {
364
        _logger.printError('Caught WIPError: $ex');
365 366
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
367
        _logger.printError('Caught FormatException: $ex');
368 369 370
        return shelf.Response.ok('Caught exception: $ex');
      }

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

378 379 380
  /// 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) {
381 382
    final String fullPath = _fileSystem.path.fromUri(request.url);
    if (!fullPath.startsWith('canvaskit/')) {
383 384 385
      return shelf.Response.notFound('Not a CanvasKit file request');
    }

386 387
    final String relativePath = fullPath.replaceFirst('canvaskit/', '');
    final String extension = _fileSystem.path.extension(relativePath);
388 389 390 391 392 393 394 395 396 397 398 399 400
    String contentType;
    switch (extension) {
      case '.js':
        contentType = 'text/javascript';
      case '.wasm':
        contentType = 'application/wasm';
      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(
401
      _canvasKitFile(relativePath).openRead(),
402 403 404 405 406 407
      headers: <String, Object>{
        HttpHeaders.contentTypeHeader: contentType,
      },
    );
  }

408 409
  // A handler that serves wrapper files used to bootstrap tests.
  shelf.Response _wrapperHandler(shelf.Request request) {
410
    final String path = _fileSystem.path.fromUri(request.url);
411
    if (path.endsWith('.html')) {
412
      final String test = '${_fileSystem.path.withoutExtension(path)}.dart';
413
      final String scriptBase = htmlEscape.convert(_fileSystem.path.basename(test));
414 415 416 417 418 419
      final String link = '<link rel="x-dart-test" href="$scriptBase">';
      return shelf.Response.ok('''
        <!DOCTYPE html>
        <html>
        <head>
          <title>${htmlEscape.convert(test)} Test</title>
420 421 422 423 424
          <script>
            window.flutterConfiguration = {
              canvasKitBaseUrl: "/canvaskit/"
            };
          </script>
425 426 427 428 429 430 431 432 433 434
          $link
          <script src="static/dart.js"></script>
        </head>
        </html>
      ''', headers: <String, String>{'Content-Type': 'text/html'});
    }
    return shelf.Response.notFound('Not found.');
  }

  @override
435 436 437 438 439 440
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Object message,
  ) async {
441
    if (_closed) {
442
      throw StateError('Load called on a closed FlutterWebPlatform');
443
    }
444 445
    final PoolResource lockResource = await _suiteLock.request();

446
    final Runtime browser = platform.runtime;
447 448 449 450 451 452 453 454
    try {
      _browserManager = await _launchBrowser(browser);
    } on Error catch (_) {
      await _suiteLock.close();
      rethrow;
    }

    if (_closed) {
455
      throw StateError('Load called on a closed FlutterWebPlatform');
456 457
    }

458 459
    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'));
460
    final String relativePath = _fileSystem.path.relative(_fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path);
461 462
    final RunnerSuite suite = await _browserManager!.load(relativePath, suiteUrl, suiteConfig, message, onDone: () async {
      await _browserManager!.close();
463 464 465
      _browserManager = null;
      lockResource.release();
    });
466
    if (_closed) {
467
      throw StateError('Load called on a closed FlutterWebPlatform');
468 469 470 471 472 473 474
    }
    return suite;
  }

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

480 481
    final Completer<WebSocketChannel> completer = Completer<WebSocketChannel>.sync();
    final String path = _webSocketHandler.create(webSocketHandler(completer.complete));
482 483
    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
    final Uri hostUrl = url
484 485 486 487 488
      .resolve('static/index.html')
      .replace(queryParameters: <String, String>{
        'managerUrl': webSocketUrl.toString(),
        'debug': _config.pauseAfterLoad.toString(),
      });
489

490
    _logger.printTrace('Serving tests at $hostUrl');
491

492
    return BrowserManager.start(
493
      _chromiumLauncher,
494 495 496
      browser,
      hostUrl,
      completer.future,
497
      headless: !_config.pauseAfterLoad,
498 499 500 501
    );
  }

  @override
502 503
  Future<void> closeEphemeral() async {
    if (_browserManager != null) {
504
      await _browserManager!.close();
505
    }
506 507 508 509
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
510 511
    await Future.wait<void>(<Future<dynamic>>[
      if (_browserManager != null)
512
        _browserManager!.close(),
513 514 515
      _server.close(),
      _testGoldenComparator.close(),
    ]);
516
  });
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543
}

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) {
544
    final List<String> components = request.url.path.split('/');
545 546 547 548
    if (components.isEmpty) {
      return shelf.Response.notFound(null);
    }
    final String path = components.removeAt(0);
549
    final FutureOr<shelf.Response> Function(shelf.Request)? handler =
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568
        _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), () {
569
      for (final RunnerSuiteController controller in _controllers) {
570 571 572 573 574
        controller.setDebugging(true);
      }
    })
      ..cancel();

575 576
    // 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.
577
    _channel = MultiChannel<dynamic>(
578 579
      webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object?> stream) {
        return stream.map((Object? message) {
580 581 582
          if (!_closed) {
            _timer.reset();
          }
583
          for (final RunnerSuiteController controller in _controllers) {
584 585
            controller.setDebugging(false);
          }
586

587 588 589 590
          return message;
        });
      }),
    );
591 592 593 594 595 596

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

  /// The browser instance that this is connected to via [_channel].
597
  final Chromium _browser;
598 599 600 601 602
  final Runtime _runtime;

  /// The channel used to communicate with the browser.
  ///
  /// This is connected to a page running `static/host.dart`.
603
  late MultiChannel<dynamic> _channel;
604 605 606 607 608 609 610 611 612 613 614 615 616 617

  /// 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.
618
  CancelableCompleter<dynamic>? _pauseCompleter;
619 620 621 622 623 624

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

  /// The environment to attach to each suite.
625
  late Future<_BrowserEnvironment> _environment;
626 627 628 629 630 631 632 633 634 635 636

  /// 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.
637
  late RestartableTimer _timer;
638 639 640 641 642 643 644 645 646 647

  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.
  ///
648 649
  /// The browser will start in headless mode if [headless] is true.
  ///
650 651
  /// Add arbitrary browser flags via [webBrowserFlags].
  ///
652 653 654 655 656
  /// 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(
657
    ChromiumLauncher chromiumLauncher,
658 659 660 661
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
662
    bool headless = true,
663
    List<String> webBrowserFlags = const <String>[],
664
  }) async {
665 666 667 668 669
    final Chromium chrome = await chromiumLauncher.launch(
      url.toString(),
      headless: headless,
      webBrowserFlags: webBrowserFlags,
    );
670 671
    final Completer<BrowserManager> completer = Completer<BrowserManager>();

672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698
    unawaited(chrome.onExit.then<Object?>(
      (int? browserExitCode) {
        throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
      },
    ).then(
      (Object? obj) => obj,
      onError: (Object error, StackTrace stackTrace) {
        if (!completer.isCompleted) {
          completer.completeError(error, stackTrace);
        }
        return null;
      },
    ));
    unawaited(future.then(
      (WebSocketChannel webSocket) {
        if (completer.isCompleted) {
          return;
        }
        completer.complete(BrowserManager._(chrome, runtime, webSocket));
      },
      onError: (Object error, StackTrace stackTrace) {
        chrome.close();
        if (!completer.isCompleted) {
          completer.completeError(error, stackTrace);
        }
      },
    ));
699

700
    return completer.future;
701 702 703 704 705
  }

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

709
  /// Tells the browser to load a test suite from the URL [url].
710 711 712 713 714 715 716 717
  ///
  /// [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(
718 719 720
    String path,
    Uri url,
    SuiteConfiguration suiteConfig,
721
    Object message, {
722
      Future<void> Function()? onDone,
723
    }
724
  ) async {
725
    url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
726
      'metadata': suiteConfig.metadata.serialize(),
727
      'browser': _runtime.identifier,
728 729 730
    })));

    final int suiteID = _suiteID++;
731
    RunnerSuiteController? controller;
732 733 734 735 736 737 738 739 740 741 742 743 744 745
    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(
746 747 748
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
749
        onDone!();
750 751
      }),
    );
752

753 754 755 756 757 758
    _channel.sink.add(<String, Object>{
      'command': 'loadSuite',
      'url': url.toString(),
      'id': suiteID,
      'channel': suiteChannelID,
    });
759

760 761 762
    try {
      controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
        suiteConfig, await _environment, suiteChannel, message);
763

764 765 766 767 768 769 770
      _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;
    }
771 772 773 774 775
  }

  /// An implementation of [Environment.displayPause].
  CancelableOperation<dynamic> _displayPause() {
    if (_pauseCompleter != null) {
776
      return _pauseCompleter!.operation;
777 778 779 780 781
    }
    _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
      _channel.sink.add(<String, String>{'command': 'resume'});
      _pauseCompleter = null;
    });
782
    _pauseCompleter!.operation.value.whenComplete(() {
783 784 785 786
      _pauseCompleter = null;
    });
    _channel.sink.add(<String, String>{'command': 'displayPause'});

787
    return _pauseCompleter!.operation;
788 789 790 791
  }

  /// The callback for handling messages received from the host page.
  void _onMessage(dynamic message) {
792 793
    assert(message is Map<String, dynamic>);
    if (message is Map<String, dynamic>) {
794
      switch (message['command'] as String?) {
795 796 797 798 799 800
        case 'ping':
          break;
        case 'restart':
          _onRestartController.add(null);
        case 'resume':
          if (_pauseCompleter != null) {
801
            _pauseCompleter!.complete();
802 803
          }
        default:
804
        // Unreachable.
805 806 807
          assert(false);
          break;
      }
808 809 810 811 812 813 814 815 816 817
    }
  }

  /// 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) {
818
        _pauseCompleter!.complete();
819 820 821 822 823 824 825 826 827 828 829 830
      }
      _pauseCompleter = null;
      _controllers.clear();
      return _browser.close();
    });
  }
}

/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
831 832 833 834 835 836
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );
837 838 839 840 841 842 843

  final BrowserManager _manager;

  @override
  final bool supportsDebugging = true;

  @override
844
  final Uri? observatoryUrl;
845 846 847 848 849 850 851 852 853 854

  @override
  final Uri remoteDebuggerUrl;

  @override
  final Stream<dynamic> onRestart;

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