// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
import 'dart:typed_data';

import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:package_config/package_config.dart';
import 'package:pool/pool.dart';
import 'package:process/process.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_core/src/platform.dart'; // ignore: implementation_imports
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;

import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../build_info.dart';
import '../cache.dart';
import '../convert.dart';
import '../dart/package_map.dart';
import '../project.dart';
import '../web/bootstrap.dart';
import '../web/chrome.dart';
import '../web/compile.dart';
import '../web/memory_fs.dart';
import 'flutter_web_goldens.dart';
import 'test_compiler.dart';
import 'test_time_recorder.dart';

class FlutterWebPlatform extends PlatformPlugin {
  FlutterWebPlatform._(this._server, this._config, this._root, {
    FlutterProject? flutterProject,
    String? shellPath,
    this.updateGoldens,
    this.nullAssertions,
    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,
    TestTimeRecorder? testTimeRecorder,
  }) : _fileSystem = fileSystem,
      _flutterToolPackageConfig = flutterToolPackageConfig,
      _chromiumLauncher = chromiumLauncher,
      _logger = logger,
      _artifacts = artifacts,
      _cache = cache {
    final shelf.Cascade cascade = shelf.Cascade()
        .add(_webSocketHandler.handler)
        .add(createStaticHandler(
          fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools'),
          serveFilesOutsidePath: true,
        ))
        .add(_handleStaticArtifact)
        .add(_localCanvasKitHandler)
        .add(_goldenFileHandler)
        .add(_wrapperHandler)
        .add(_handleTestRequest)
        .add(createStaticHandler(
          fileSystem.path.join(fileSystem.currentDirectory.path, 'test'),
          serveFilesOutsidePath: true,
        ))
        .add(_packageFilesHandler);
    _server.mount(cascade.handler);
    _testGoldenComparator = TestGoldenComparator(
      shellPath,
      () => TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
      fileSystem: _fileSystem,
      logger: _logger,
      processManager: processManager,
      webRenderer: _rendererMode,
    );
  }

  final WebMemoryFS webMemoryFS;
  final BuildInfo buildInfo;
  final FileSystem _fileSystem;
  final PackageConfig _flutterToolPackageConfig;
  final ChromiumLauncher _chromiumLauncher;
  final Logger _logger;
  final Artifacts? _artifacts;
  final bool? updateGoldens;
  final bool? nullAssertions;
  final OneOffHandler _webSocketHandler = OneOffHandler();
  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
  final String _root;
  final Cache _cache;

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

  static Future<FlutterWebPlatform> start(String root, {
    FlutterProject? flutterProject,
    String? shellPath,
    bool updateGoldens = false,
    bool pauseAfterLoad = false,
    bool nullAssertions = false,
    required BuildInfo buildInfo,
    required WebMemoryFS webMemoryFS,
    required FileSystem fileSystem,
    required Logger logger,
    required ChromiumLauncher chromiumLauncher,
    required Artifacts? artifacts,
    required ProcessManager processManager,
    required Cache cache,
    TestTimeRecorder? testTimeRecorder,
  }) async {
    final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
      fileSystem.file(fileSystem.path.join(
        Cache.flutterRoot!,
        'packages',
        'flutter_tools',
        '.dart_tool',
        'package_config.json',
      )),
      logger: logger,
    );
    return FlutterWebPlatform._(
      server,
      Configuration.current.change(pauseAfterLoad: pauseAfterLoad),
      root,
      flutterProject: flutterProject,
      shellPath: shellPath,
      updateGoldens: updateGoldens,
      buildInfo: buildInfo,
      webMemoryFS: webMemoryFS,
      flutterToolPackageConfig: packageConfig,
      fileSystem: fileSystem,
      chromiumLauncher: chromiumLauncher,
      artifacts: artifacts,
      logger: logger,
      nullAssertions: nullAssertions,
      processManager: processManager,
      cache: cache,
      testTimeRecorder: testTimeRecorder,
    );
  }

  bool get _closed => _closeMemo.hasRun;

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

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

  final Configuration _config;
  final shelf.Server _server;
  Uri get url => _server.url;

  /// The ahem text file.
  File get _ahem => _fileSystem.file(_fileSystem.path.join(
    Cache.flutterRoot!,
    'packages',
    'flutter_tools',
    'static',
    'Ahem.ttf',
  ));

  /// The require js binary.
  File get _requireJs => _fileSystem.file(_fileSystem.path.join(
        _artifacts!.getArtifactPath(Artifact.engineDartSdkPath, platform: TargetPlatform.web_javascript),
        'lib',
        'dev_compiler',
        'amd',
        'require.js',
      ));

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

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

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

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

  File get _testHostDartJs => _fileSystem.file(_fileSystem.path.join(
    testUri.toFilePath(),
    'src',
    'runner',
    'browser',
    'static',
    'host.dart.js',
  ));

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

  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];
      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>{
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.bootstrap.js')) {
      final String leadingPath = request.url.path.split('.dart.bootstrap.js')[0];
      final String generatedFile = '${_fileSystem.path.split(leadingPath).join('_')}.dart.test.dart.js';
      return shelf.Response.ok(generateMainModule(
        nullAssertions: nullAssertions!,
        nativeNullAssertions: true,
        bootstrapModule: '${_fileSystem.path.basename(leadingPath)}.dart.bootstrap',
        entrypoint: '/$generatedFile'
       ), headers: <String, String>{
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.dart.js')) {
      final String path = request.url.path.split('.dart.js')[0];
      return shelf.Response.ok(webMemoryFS.files['$path.dart.lib.js'], headers: <String, String>{
        HttpHeaders.contentTypeHeader: 'text/javascript',
      });
    }
    if (request.url.path.endsWith('.lib.js.map')) {
      return shelf.Response.ok(webMemoryFS.sourcemaps[request.url.path], headers: <String, String>{
        HttpHeaders.contentTypeHeader: 'text/plain',
      });
    }
    return shelf.Response.notFound('');
  }

  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
    if (request.requestedUri.path.contains('require.js')) {
      return shelf.Response.ok(
        _requireJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('ahem.ttf')) {
      return shelf.Response.ok(_ahem.openRead());
    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
      return shelf.Response.ok(
        _dartSdk.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('dart_sdk.js.map')) {
      return shelf.Response.ok(
        _dartSdkSourcemaps.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path
        .contains('dart_stack_trace_mapper.js')) {
      return shelf.Response.ok(
        _stackTraceMapper.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('static/dart.js')) {
      return shelf.Response.ok(
        _testDartJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('host.dart.js')) {
      return shelf.Response.ok(
        _testHostDartJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

  FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
    if (request.requestedUri.pathSegments.first == 'packages') {
      final Uri? fileUri = buildInfo.packageConfig.resolve(Uri(
        scheme: 'package',
        pathSegments: request.requestedUri.pathSegments.skip(1),
      ));
      if (fileUri != null) {
        final String dirname = _fileSystem.path.dirname(fileUri.toFilePath());
        final String basename = _fileSystem.path.basename(fileUri.toFilePath());
        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);
      }
    }
    return shelf.Response.notFound('Not Found');
  }

  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 {
        final ChromeTab chromeTab = (await _browserManager!._browser.chromeConnection.getTab((ChromeTab tab) {
          return tab.url.contains(_browserManager!._browser.url!);
        }))!;
        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) {
        _logger.printError('Caught WIPError: $ex');
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
        _logger.printError('Caught FormatException: $ex');
        return shelf.Response.ok('Caught exception: $ex');
      }

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

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

  // A handler that serves wrapper files used to bootstrap tests.
  shelf.Response _wrapperHandler(shelf.Request request) {
    final String path = _fileSystem.path.fromUri(request.url);
    if (path.endsWith('.html')) {
      final String test = '${_fileSystem.path.withoutExtension(path)}.dart';
      final String scriptBase = htmlEscape.convert(_fileSystem.path.basename(test));
      final String link = '<link rel="x-dart-test" href="$scriptBase">';
      return shelf.Response.ok('''
        <!DOCTYPE html>
        <html>
        <head>
          <title>${htmlEscape.convert(test)} Test</title>
          <script>
            window.flutterConfiguration = {
              canvasKitBaseUrl: "/canvaskit/"
            };
          </script>
          $link
          <script src="static/dart.js"></script>
        </head>
        </html>
      ''', headers: <String, String>{'Content-Type': 'text/html'});
    }
    return shelf.Response.notFound('Not found.');
  }

  @override
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Object message,
  ) async {
    if (_closed) {
      throw StateError('Load called on a closed FlutterWebPlatform');
    }
    final PoolResource lockResource = await _suiteLock.request();

    final Runtime browser = platform.runtime;
    try {
      _browserManager = await _launchBrowser(browser);
    } on Error catch (_) {
      await _suiteLock.close();
      rethrow;
    }

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

    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'));
    final String relativePath = _fileSystem.path.relative(_fileSystem.path.normalize(path), from: _fileSystem.currentDirectory.path);
    final RunnerSuite suite = await _browserManager!.load(relativePath, suiteUrl, suiteConfig, message, onDone: () async {
      await _browserManager!.close();
      _browserManager = null;
      lockResource.release();
    });
    if (_closed) {
      throw StateError('Load called on a closed FlutterWebPlatform');
    }
    return suite;
  }

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

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

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

    return BrowserManager.start(
      _chromiumLauncher,
      browser,
      hostUrl,
      completer.future,
      headless: !_config.pauseAfterLoad,
    );
  }

  @override
  Future<void> closeEphemeral() async {
    if (_browserManager != null) {
      await _browserManager!.close();
    }
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
    await Future.wait<void>(<Future<dynamic>>[
      if (_browserManager != null)
        _browserManager!.close(),
      _server.close(),
      _testGoldenComparator.close(),
    ]);
  });
}

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) {
    final List<String> components = request.url.path.split('/');
    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), () {
      for (final RunnerSuiteController controller in _controllers) {
        controller.setDebugging(true);
      }
    })
      ..cancel();

    // 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.
    _channel = MultiChannel<dynamic>(
      webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object?> stream) {
        return stream.map((Object? message) {
          if (!_closed) {
            _timer.reset();
          }
          for (final RunnerSuiteController controller in _controllers) {
            controller.setDebugging(false);
          }

          return message;
        });
      }),
    );

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

  /// The browser instance that this is connected to via [_channel].
  final Chromium _browser;
  final Runtime _runtime;

  /// The channel used to communicate with the browser.
  ///
  /// This is connected to a page running `static/host.dart`.
  late 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.
  late 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.
  late 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.
  ///
  /// The browser will start in headless mode if [headless] is true.
  ///
  /// Add arbitrary browser flags via [webBrowserFlags].
  ///
  /// 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(
    ChromiumLauncher chromiumLauncher,
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
    bool headless = true,
    List<String> webBrowserFlags = const <String>[],
  }) async {
    final Chromium chrome = await chromiumLauncher.launch(
      url.toString(),
      headless: headless,
      webBrowserFlags: webBrowserFlags,
    );
    final Completer<BrowserManager> completer = Completer<BrowserManager>();

    unawaited(chrome.onExit.then<Object?>((int? browserExitCode) {
      throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
    }).catchError((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));
    }).catchError((Object error, StackTrace stackTrace) {
      chrome.close();
      if (!completer.isCompleted) {
        completer.completeError(error, stackTrace);
      }
    }));

    return completer.future;
  }

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

  /// Tells the browser to load a test suite from the URL [url].
  ///
  /// [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(
    String path,
    Uri url,
    SuiteConfiguration suiteConfig,
    Object message, {
      Future<void> Function()? onDone,
    }
  ) async {
    url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
      'metadata': suiteConfig.metadata.serialize(),
      'browser': _runtime.identifier,
    })));

    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(
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
        onDone!();
      }),
    );

    _channel.sink.add(<String, Object>{
      'command': 'loadSuite',
      'url': url.toString(),
      'id': suiteID,
      'channel': suiteChannelID,
    });

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

      _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;
    }
  }

  /// 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) {
    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:
        // Unreachable.
          assert(false);
          break;
      }
    }
  }

  /// Closes the manager and releases any resources it owns, including closing
  /// the browser.
  Future<dynamic> close() {
    return _closeMemoizer.runOnce(() {
      _closed = true;
      _timer.cancel();
      if (_pauseCompleter != null) {
        _pauseCompleter!.complete();
      }
      _pauseCompleter = null;
      _controllers.clear();
      return _browser.close();
    });
  }
}

/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );

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