1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:typed_data';
7
8import 'package:async/async.dart';
9import 'package:http_multi_server/http_multi_server.dart';
10import 'package:mime/mime.dart' as mime;
11import 'package:package_config/package_config.dart';
12import 'package:pool/pool.dart';
13import 'package:process/process.dart';
14import 'package:shelf/shelf.dart' as shelf;
15import 'package:shelf/shelf_io.dart' as shelf_io;
16import 'package:shelf_web_socket/shelf_web_socket.dart';
17import 'package:stream_channel/stream_channel.dart';
18import 'package:test_core/src/platform.dart'; // ignore: implementation_imports
19import 'package:web_socket_channel/web_socket_channel.dart';
20
21import '../artifacts.dart';
22import '../base/common.dart';
23import '../base/file_system.dart';
24import '../base/io.dart';
25import '../base/logger.dart';
26import '../build_info.dart';
27import '../cache.dart';
28import '../convert.dart';
29import '../dart/package_map.dart';
30import '../project.dart';
31import '../web/bootstrap.dart';
32import '../web/chrome.dart';
33import '../web/compile.dart';
34import '../web/memory_fs.dart';
35import '../web/web_constants.dart';
36import 'test_compiler.dart';
37import 'test_golden_comparator.dart';
38import 'test_time_recorder.dart';
39
40shelf.Handler createDirectoryHandler(Directory directory, {required bool crossOriginIsolated}) {
41 final resolver = mime.MimeTypeResolver();
42 final FileSystem fileSystem = directory.fileSystem;
43 return (shelf.Request request) async {
44 String uriPath = request.requestedUri.path;
45
46 // Strip any leading slashes
47 if (uriPath.startsWith('/')) {
48 uriPath = uriPath.substring(1);
49 }
50 final String filePath = fileSystem.path.join(directory.path, uriPath);
51 final File file = fileSystem.file(filePath);
52 if (!file.existsSync()) {
53 return shelf.Response.notFound('Not Found');
54 }
55 final String? contentType = resolver.lookup(file.path);
56 final bool needsCrossOriginIsolated = crossOriginIsolated && uriPath.endsWith('.html');
57 return shelf.Response.ok(
58 file.openRead(),
59 headers: <String, String>{
60 if (contentType != null) 'Content-Type': contentType,
61 if (needsCrossOriginIsolated) ...kMultiThreadedHeaders,
62 },
63 );
64 };
65}
66
67class FlutterWebPlatform extends PlatformPlugin {
68 FlutterWebPlatform._(
69 this._server,
70 this._config,
71 this._root, {
72 required this.updateGoldens,
73 required this.buildInfo,
74 required this.webMemoryFS,
75 required FlutterProject flutterProject,
76 required String flutterTesterBinPath,
77 required FileSystem fileSystem,
78 required Directory buildDirectory,
79 required File testDartJs,
80 required File testHostDartJs,
81 required ChromiumLauncher chromiumLauncher,
82 required Logger logger,
83 required Artifacts? artifacts,
84 required ProcessManager processManager,
85 required this.webRenderer,
86 required this.useWasm,
87 TestTimeRecorder? testTimeRecorder,
88 }) : _fileSystem = fileSystem,
89 _buildDirectory = buildDirectory,
90 _testDartJs = testDartJs,
91 _testHostDartJs = testHostDartJs,
92 _chromiumLauncher = chromiumLauncher,
93 _logger = logger,
94 _artifacts = artifacts {
95 final shelf.Cascade cascade = shelf.Cascade()
96 .add(_webSocketHandler.handler)
97 .add(
98 createDirectoryHandler(
99 fileSystem.directory(
100 fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools'),
101 ),
102 crossOriginIsolated: webRenderer == WebRendererMode.skwasm,
103 ),
104 )
105 .add(_handleStaticArtifact)
106 .add(_localCanvasKitHandler)
107 .add(_goldenFileHandler)
108 .add(_wrapperHandler)
109 .add(_handleTestRequest)
110 .add(
111 createDirectoryHandler(
112 fileSystem.directory(fileSystem.path.join(fileSystem.currentDirectory.path, 'test')),
113 crossOriginIsolated: webRenderer == WebRendererMode.skwasm,
114 ),
115 )
116 .add(_packageFilesHandler);
117 _server.mount(cascade.handler);
118 _testGoldenComparator = TestGoldenComparator(
119 compilerFactory: () =>
120 TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
121 flutterTesterBinPath: flutterTesterBinPath,
122 fileSystem: _fileSystem,
123 logger: _logger,
124 processManager: processManager,
125 environment: <String, String>{
126 // Chrome is the only supported browser currently.
127 'FLUTTER_TEST_BROWSER': 'chrome',
128 'FLUTTER_WEB_RENDERER': webRenderer.name,
129 },
130 );
131 }
132
133 final WebMemoryFS webMemoryFS;
134 final BuildInfo buildInfo;
135 final FileSystem _fileSystem;
136 final Directory _buildDirectory;
137 final File _testDartJs;
138 final File _testHostDartJs;
139 final ChromiumLauncher _chromiumLauncher;
140 final Logger _logger;
141 final Artifacts? _artifacts;
142 final bool updateGoldens;
143 final _webSocketHandler = OneOffHandler();
144 final _closeMemo = AsyncMemoizer<void>();
145 final String _root;
146 final WebRendererMode webRenderer;
147 final bool useWasm;
148
149 /// Allows only one test suite (typically one test file) to be loaded and run
150 /// at any given point in time. Loading more than one file at a time is known
151 /// to lead to flaky tests.
152 final _suiteLock = Pool(1);
153
154 BrowserManager? _browserManager;
155 late TestGoldenComparator _testGoldenComparator;
156
157 static Future<shelf.Server> defaultServerFactory() async {
158 return shelf_io.IOServer(await HttpMultiServer.loopback(0));
159 }
160
161 static Future<FlutterWebPlatform> start(
162 String root, {
163 bool updateGoldens = false,
164 bool pauseAfterLoad = false,
165 required FlutterProject flutterProject,
166 required String flutterTesterBinPath,
167 required BuildInfo buildInfo,
168 required WebMemoryFS webMemoryFS,
169 required FileSystem fileSystem,
170 required Directory buildDirectory,
171 required Logger logger,
172 required ChromiumLauncher chromiumLauncher,
173 required Artifacts? artifacts,
174 required ProcessManager processManager,
175 required WebRendererMode webRenderer,
176 required bool useWasm,
177 TestTimeRecorder? testTimeRecorder,
178 Uri? testPackageUri,
179 Future<shelf.Server> Function() serverFactory = defaultServerFactory,
180 }) async {
181 final shelf.Server server = await serverFactory();
182 if (testPackageUri == null) {
183 final PackageConfig packageConfig = await currentPackageConfig();
184 testPackageUri = packageConfig['test']!.packageUriRoot;
185 }
186 final File testDartJs = fileSystem.file(
187 fileSystem.path.join(testPackageUri.toFilePath(), 'dart.js'),
188 );
189 final File testHostDartJs = fileSystem.file(
190 fileSystem.path.join(
191 testPackageUri.toFilePath(),
192 'src',
193 'runner',
194 'browser',
195 'static',
196 'host.dart.js',
197 ),
198 );
199 return FlutterWebPlatform._(
200 server,
201 Configuration.current.change(pauseAfterLoad: pauseAfterLoad),
202 root,
203 flutterProject: flutterProject,
204 flutterTesterBinPath: flutterTesterBinPath,
205 updateGoldens: updateGoldens,
206 buildInfo: buildInfo,
207 webMemoryFS: webMemoryFS,
208 testDartJs: testDartJs,
209 testHostDartJs: testHostDartJs,
210 fileSystem: fileSystem,
211 buildDirectory: buildDirectory,
212 chromiumLauncher: chromiumLauncher,
213 artifacts: artifacts,
214 logger: logger,
215 processManager: processManager,
216 webRenderer: webRenderer,
217 useWasm: useWasm,
218 testTimeRecorder: testTimeRecorder,
219 );
220 }
221
222 bool get _closed => _closeMemo.hasRun;
223
224 final Configuration _config;
225 final shelf.Server _server;
226 Uri get url => _server.url;
227
228 /// The ahem text file.
229 File get _ahem => _fileSystem.file(
230 _fileSystem.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools', 'static', 'Ahem.ttf'),
231 );
232
233 /// The require js binary.
234 File get _requireJs => _fileSystem.file(
235 _fileSystem.path.join(
236 _artifacts!.getArtifactPath(
237 Artifact.engineDartSdkPath,
238 platform: TargetPlatform.web_javascript,
239 ),
240 'lib',
241 'dev_compiler',
242 'amd',
243 'require.js',
244 ),
245 );
246
247 /// The ddc module loader js binary.
248 File get _ddcModuleLoaderJs => _fileSystem.file(
249 _fileSystem.path.join(
250 _artifacts!.getArtifactPath(
251 Artifact.engineDartSdkPath,
252 platform: TargetPlatform.web_javascript,
253 ),
254 'lib',
255 'dev_compiler',
256 'ddc',
257 'ddc_module_loader.js',
258 ),
259 );
260
261 /// The ddc to dart stack trace mapper.
262 File get _stackTraceMapper => _fileSystem.file(
263 _fileSystem.path.join(
264 _artifacts!.getArtifactPath(
265 Artifact.engineDartSdkPath,
266 platform: TargetPlatform.web_javascript,
267 ),
268 'lib',
269 'dev_compiler',
270 'web',
271 'dart_stack_trace_mapper.js',
272 ),
273 );
274
275 File get _flutterJs => _fileSystem.file(
276 _fileSystem.path.join(
277 _artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
278 'flutter.js',
279 ),
280 );
281
282 File get _dartSdk {
283 // TODO(srujzs): Remove this assertion when the library bundle format is
284 // supported without canary mode.
285 if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) {
286 assert(buildInfo.canaryFeatures);
287 }
288 final Map<WebRendererMode, HostArtifact> dartSdkArtifactMap =
289 buildInfo.ddcModuleFormat == DdcModuleFormat.ddc
290 ? kDdcLibraryBundleDartSdkJsArtifactMap
291 : kAmdDartSdkJsArtifactMap;
292 return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!));
293 }
294
295 File get _dartSdkSourcemaps {
296 // TODO(srujzs): Remove this assertion when the library bundle format is
297 // supported without canary mode.
298 if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) {
299 assert(buildInfo.canaryFeatures);
300 }
301 final Map<WebRendererMode, HostArtifact> dartSdkArtifactMap =
302 buildInfo.ddcModuleFormat == DdcModuleFormat.ddc
303 ? kDdcLibraryBundleDartSdkJsMapArtifactMap
304 : kAmdDartSdkJsMapArtifactMap;
305 return _fileSystem.file(_artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!));
306 }
307
308 File _canvasKitFile(String relativePath) {
309 final String canvasKitPath = _fileSystem.path.join(
310 _artifacts!.getHostArtifact(HostArtifact.flutterWebSdk).path,
311 'canvaskit',
312 );
313 final File canvasKitFile = _fileSystem.file(_fileSystem.path.join(canvasKitPath, relativePath));
314 return canvasKitFile;
315 }
316
317 Future<shelf.Response> _handleTestRequest(shelf.Request request) async {
318 if (request.url.path.endsWith('main.dart.browser_test.dart.js')) {
319 return shelf.Response.ok(
320 generateTestBootstrapFileContents(
321 '/main.dart.bootstrap.js',
322 'require.js',
323 'dart_stack_trace_mapper.js',
324 ),
325 headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/javascript'},
326 );
327 }
328 if (request.url.path.endsWith('main.dart.bootstrap.js')) {
329 return shelf.Response.ok(
330 generateMainModule(
331 nativeNullAssertions: true,
332 bootstrapModule: 'main.dart.bootstrap',
333 entrypoint: '/main.dart.js',
334 ),
335 headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/javascript'},
336 );
337 }
338 if (request.url.path.endsWith('.dart.js')) {
339 final String path = request.url.path.split('.dart.js')[0];
340 return shelf.Response.ok(
341 webMemoryFS.files['$path.dart.lib.js'],
342 headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/javascript'},
343 );
344 }
345 if (request.url.path.endsWith('.lib.js.map')) {
346 return shelf.Response.ok(
347 webMemoryFS.sourcemaps[request.url.path],
348 headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/plain'},
349 );
350 }
351 return shelf.Response.notFound('');
352 }
353
354 Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
355 if (request.requestedUri.path.contains('require.js')) {
356 return shelf.Response.ok(
357 _requireJs.openRead(),
358 headers: <String, String>{'Content-Type': 'text/javascript'},
359 );
360 } else if (request.requestedUri.path.contains('ddc_module_loader.js')) {
361 return shelf.Response.ok(
362 _ddcModuleLoaderJs.openRead(),
363 headers: <String, String>{'Content-Type': 'text/javascript'},
364 );
365 } else if (request.requestedUri.path.contains('ahem.ttf')) {
366 return shelf.Response.ok(_ahem.openRead());
367 } else if (request.requestedUri.path.contains('dart_sdk.js')) {
368 return shelf.Response.ok(
369 _dartSdk.openRead(),
370 headers: <String, String>{'Content-Type': 'text/javascript'},
371 );
372 } else if (request.requestedUri.path.contains('dart_sdk.js.map')) {
373 return shelf.Response.ok(
374 _dartSdkSourcemaps.openRead(),
375 headers: <String, String>{'Content-Type': 'text/javascript'},
376 );
377 } else if (request.requestedUri.path.contains('dart_stack_trace_mapper.js')) {
378 return shelf.Response.ok(
379 _stackTraceMapper.openRead(),
380 headers: <String, String>{'Content-Type': 'text/javascript'},
381 );
382 } else if (request.requestedUri.path.contains('static/dart.js')) {
383 return shelf.Response.ok(
384 _testDartJs.openRead(),
385 headers: <String, String>{'Content-Type': 'text/javascript'},
386 );
387 } else if (request.requestedUri.path.contains('host.dart.js')) {
388 return shelf.Response.ok(
389 _testHostDartJs.openRead(),
390 headers: <String, String>{'Content-Type': 'text/javascript'},
391 );
392 } else if (request.requestedUri.path.contains('flutter.js')) {
393 return shelf.Response.ok(
394 _flutterJs.openRead(),
395 headers: <String, String>{'Content-Type': 'text/javascript'},
396 );
397 } else if (request.requestedUri.path.contains('main.dart.mjs')) {
398 return shelf.Response.ok(
399 _buildDirectory.childFile('main.dart.mjs').openRead(),
400 headers: <String, String>{'Content-Type': 'text/javascript'},
401 );
402 } else if (request.requestedUri.path.contains('main.dart.wasm')) {
403 return shelf.Response.ok(
404 _buildDirectory.childFile('main.dart.wasm').openRead(),
405 headers: <String, String>{'Content-Type': 'application/wasm'},
406 );
407 } else {
408 return shelf.Response.notFound('Not Found');
409 }
410 }
411
412 FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
413 if (request.requestedUri.pathSegments.first == 'packages') {
414 final Uri? fileUri = buildInfo.packageConfig.resolve(
415 Uri(scheme: 'package', pathSegments: request.requestedUri.pathSegments.skip(1)),
416 );
417 if (fileUri != null) {
418 final String dirname = _fileSystem.path.dirname(fileUri.toFilePath());
419 final String basename = _fileSystem.path.basename(fileUri.toFilePath());
420 final shelf.Handler handler = createDirectoryHandler(
421 _fileSystem.directory(dirname),
422 crossOriginIsolated: webRenderer == WebRendererMode.skwasm,
423 );
424 final modifiedRequest = shelf.Request(
425 request.method,
426 request.requestedUri.replace(path: basename),
427 protocolVersion: request.protocolVersion,
428 headers: request.headers,
429 handlerPath: request.handlerPath,
430 url: request.url.replace(path: basename),
431 encoding: request.encoding,
432 context: request.context,
433 );
434 return handler(modifiedRequest);
435 }
436 }
437 return shelf.Response.notFound('Not Found');
438 }
439
440 Future<shelf.Response> _goldenFileHandler(shelf.Request request) async {
441 if (request.url.path.contains('flutter_goldens')) {
442 final body = json.decode(await request.readAsString()) as Map<String, Object?>;
443 final Uri goldenKey = Uri.parse(body['key']! as String);
444 final Uri testUri = Uri.parse(body['testUri']! as String);
445 Uint8List bytes;
446
447 if (body.containsKey('bytes')) {
448 bytes = base64.decode(body['bytes']! as String);
449 } else {
450 return shelf.Response.ok('Request must contain bytes in the body.');
451 }
452 if (updateGoldens) {
453 return switch (await _testGoldenComparator.update(testUri, bytes, goldenKey)) {
454 TestGoldenUpdateDone() => shelf.Response.ok('true'),
455 TestGoldenUpdateError(error: final String error) => shelf.Response.ok(error),
456 };
457 } else {
458 return switch (await _testGoldenComparator.compare(testUri, bytes, goldenKey)) {
459 TestGoldenComparisonDone(matched: final bool matched) => shelf.Response.ok('$matched'),
460 TestGoldenComparisonError(error: final String error) => shelf.Response.ok(error),
461 };
462 }
463 } else {
464 return shelf.Response.notFound('Not Found');
465 }
466 }
467
468 /// Serves a local build of CanvasKit, replacing the CDN build, which can
469 /// cause test flakiness due to reliance on network.
470 shelf.Response _localCanvasKitHandler(shelf.Request request) {
471 final String fullPath = _fileSystem.path.fromUri(request.url);
472 if (!fullPath.startsWith('canvaskit/')) {
473 return shelf.Response.notFound('Not a CanvasKit file request');
474 }
475
476 final String relativePath = fullPath.replaceFirst('canvaskit/', '');
477 final String extension = _fileSystem.path.extension(relativePath);
478 String contentType;
479 switch (extension) {
480 case '.js':
481 contentType = 'text/javascript';
482 case '.wasm':
483 contentType = 'application/wasm';
484 default:
485 final error = 'Failed to determine Content-Type for "${request.url.path}".';
486 _logger.printError(error);
487 return shelf.Response.internalServerError(body: error);
488 }
489
490 final File canvasKitFile = _canvasKitFile(relativePath);
491 return shelf.Response.ok(
492 canvasKitFile.openRead(),
493 headers: <String, Object>{HttpHeaders.contentTypeHeader: contentType},
494 );
495 }
496
497 String _makeBuildConfigString() {
498 return useWasm
499 ? '''
500 {
501 compileTarget: "dart2wasm",
502 renderer: "${webRenderer.name}",
503 mainWasmPath: "main.dart.wasm",
504 jsSupportRuntimePath: "main.dart.mjs",
505 }
506'''
507 : '''
508 {
509 compileTarget: "dartdevc",
510 renderer: "${webRenderer.name}",
511 mainJsPath: "main.dart.browser_test.dart.js",
512 }
513''';
514 }
515
516 // A handler that serves wrapper files used to bootstrap tests.
517 shelf.Response _wrapperHandler(shelf.Request request) {
518 final String path = _fileSystem.path.fromUri(request.url);
519 if (path.endsWith('.html')) {
520 final test = '${_fileSystem.path.withoutExtension(path)}.dart';
521 return shelf.Response.ok(
522 '''
523 <!DOCTYPE html>
524 <html>
525 <head>
526 <title>${htmlEscape.convert(test)} Test</title>
527 <script src="flutter.js"></script>
528 <script>
529 _flutter.buildConfig = {
530 builds: [
531 ${_makeBuildConfigString()}
532 ]
533 }
534 window.testSelector = "$test";
535 _flutter.loader.load({
536 config: {
537 canvasKitBaseUrl: "/canvaskit/",
538 }
539 });
540 </script>
541 </head>
542 </html>
543 ''',
544 headers: <String, String>{
545 'Content-Type': 'text/html',
546 if (webRenderer == WebRendererMode.skwasm) ...kMultiThreadedHeaders,
547 },
548 );
549 }
550 return shelf.Response.notFound('Not found.');
551 }
552
553 @override
554 Future<RunnerSuite> load(
555 String path,
556 SuitePlatform platform,
557 SuiteConfiguration suiteConfig,
558 Object message,
559 ) async {
560 if (_closed) {
561 throw StateError('Load called on a closed FlutterWebPlatform');
562 }
563
564 final String pathFromTest = _fileSystem.path.relative(
565 path,
566 from: _fileSystem.path.join(_root, 'test'),
567 );
568 final Uri suiteUrl = url.resolveUri(
569 _fileSystem.path.toUri('${_fileSystem.path.withoutExtension(pathFromTest)}.html'),
570 );
571 final String relativePath = _fileSystem.path.relative(
572 _fileSystem.path.normalize(path),
573 from: _fileSystem.currentDirectory.path,
574 );
575 if (_logger.isVerbose) {
576 _logger.printTrace('Loading test suite $relativePath.');
577 }
578
579 final PoolResource lockResource = await _suiteLock.request();
580
581 final Runtime browser = platform.runtime;
582 try {
583 _browserManager = await _launchBrowser(browser);
584 } on Error catch (_) {
585 await _suiteLock.close();
586 rethrow;
587 }
588
589 if (_closed) {
590 throw StateError('Load called on a closed FlutterWebPlatform');
591 }
592
593 if (_logger.isVerbose) {
594 _logger.printTrace('Running test suite $relativePath.');
595 }
596
597 final RunnerSuite suite = await _browserManager!.load(
598 relativePath,
599 suiteUrl,
600 suiteConfig,
601 message,
602 onDone: () async {
603 await _browserManager!.close();
604 _browserManager = null;
605 lockResource.release();
606 if (_logger.isVerbose) {
607 _logger.printTrace('Test suite $relativePath finished.');
608 }
609 },
610 );
611
612 if (_closed) {
613 throw StateError('Load called on a closed FlutterWebPlatform');
614 }
615
616 return suite;
617 }
618
619 /// Returns the [BrowserManager] for [browser], which should be a browser.
620 ///
621 /// If no browser manager is running yet, starts one.
622 Future<BrowserManager> _launchBrowser(Runtime browser) {
623 if (_browserManager != null) {
624 throw StateError('Another browser is currently running.');
625 }
626
627 final completer = Completer<WebSocketChannel>.sync();
628 final String path = _webSocketHandler.create(
629 webSocketHandler((WebSocketChannel webSocket, _) {
630 completer.complete(webSocket);
631 }),
632 );
633 final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
634 final Uri hostUrl = url
635 .resolve('static/index.html')
636 .replace(
637 queryParameters: <String, String>{
638 'managerUrl': webSocketUrl.toString(),
639 'debug': _config.pauseAfterLoad.toString(),
640 },
641 );
642
643 _logger.printTrace('Serving tests at $hostUrl');
644
645 return BrowserManager.start(
646 _chromiumLauncher,
647 browser,
648 hostUrl,
649 completer.future,
650 headless: !_config.pauseAfterLoad,
651 );
652 }
653
654 @override
655 Future<void> closeEphemeral() async {
656 if (_browserManager != null) {
657 await _browserManager!.close();
658 }
659 }
660
661 @override
662 Future<void> close() => _closeMemo.runOnce(() async {
663 await Future.wait<void>(<Future<dynamic>>[
664 if (_browserManager != null) _browserManager!.close(),
665 _server.close(),
666 _testGoldenComparator.close(),
667 ]);
668 });
669}
670
671class OneOffHandler {
672 /// A map from URL paths to handlers.
673 final _handlers = <String, shelf.Handler>{};
674
675 /// The counter of handlers that have been activated.
676 var _counter = 0;
677
678 /// The actual [shelf.Handler] that dispatches requests.
679 shelf.Handler get handler => _onRequest;
680
681 /// Creates a new one-off handler that forwards to [handler].
682 ///
683 /// Returns a string that's the URL path for hitting this handler, relative to
684 /// the URL for the one-off handler itself.
685 ///
686 /// [handler] will be unmounted as soon as it receives a request.
687 String create(shelf.Handler handler) {
688 final path = _counter.toString();
689 _handlers[path] = handler;
690 _counter++;
691 return path;
692 }
693
694 /// Dispatches [request] to the appropriate handler.
695 FutureOr<shelf.Response> _onRequest(shelf.Request request) {
696 final List<String> components = request.url.path.split('/');
697 if (components.isEmpty) {
698 return shelf.Response.notFound(null);
699 }
700 final String path = components.removeAt(0);
701 final FutureOr<shelf.Response> Function(shelf.Request)? handler = _handlers.remove(path);
702 if (handler == null) {
703 return shelf.Response.notFound(null);
704 }
705 return handler(request.change(path: path));
706 }
707}
708
709class BrowserManager {
710 /// Creates a new BrowserManager that communicates with [_browser] over
711 /// [webSocket].
712 BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) {
713 // The duration should be short enough that the debugging console is open as
714 // soon as the user is done setting breakpoints, but long enough that a test
715 // doing a lot of synchronous work doesn't trigger a false positive.
716 //
717 // Start this canceled because we don't want it to start ticking until we
718 // get some response from the iframe.
719 _timer = RestartableTimer(const Duration(seconds: 3), () {
720 for (final RunnerSuiteController controller in _controllers) {
721 controller.setDebugging(true);
722 }
723 })..cancel();
724
725 // Whenever we get a message, no matter which child channel it's for, we know
726 // the browser is still running code which means the user isn't debugging.
727 _channel = MultiChannel<dynamic>(
728 webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object?> stream) {
729 return stream.map((Object? message) {
730 if (!_closed) {
731 _timer.reset();
732 }
733 for (final RunnerSuiteController controller in _controllers) {
734 controller.setDebugging(false);
735 }
736
737 return message;
738 });
739 }),
740 );
741
742 _environment = _loadBrowserEnvironment();
743 _channel.stream.listen(_onMessage, onDone: close);
744 }
745
746 /// The browser instance that this is connected to via [_channel].
747 final Chromium _browser;
748 final Runtime _runtime;
749
750 /// The channel used to communicate with the browser.
751 ///
752 /// This is connected to a page running `static/host.dart`.
753 late MultiChannel<dynamic> _channel;
754
755 /// The ID of the next suite to be loaded.
756 ///
757 /// This is used to ensure that the suites can be referred to consistently
758 /// across the client and server.
759 var _suiteID = 0;
760
761 /// Whether the channel to the browser has closed.
762 var _closed = false;
763
764 /// The completer for [_BrowserEnvironment.displayPause].
765 ///
766 /// This will be `null` as long as the browser isn't displaying a pause
767 /// screen.
768 CancelableCompleter<dynamic>? _pauseCompleter;
769
770 /// The controller for [_BrowserEnvironment.onRestart].
771 final _onRestartController = StreamController<dynamic>.broadcast();
772
773 /// The environment to attach to each suite.
774 late Future<_BrowserEnvironment> _environment;
775
776 /// Controllers for every suite in this browser.
777 ///
778 /// These are used to mark suites as debugging or not based on the browser's
779 /// pings.
780 final _controllers = <RunnerSuiteController>{};
781
782 // A timer that's reset whenever we receive a message from the browser.
783 //
784 // Because the browser stops running code when the user is actively debugging,
785 // this lets us detect whether they're debugging reasonably accurately.
786 late RestartableTimer _timer;
787
788 final _closeMemoizer = AsyncMemoizer<dynamic>();
789
790 /// Starts the browser identified by [runtime] and has it connect to [url].
791 ///
792 /// [url] should serve a page that establishes a WebSocket connection with
793 /// this process. That connection, once established, should be emitted via
794 /// [future]. If [debug] is true, starts the browser in debug mode, with its
795 /// debugger interfaces on and detected.
796 ///
797 /// The browser will start in headless mode if [headless] is `true`.
798 ///
799 /// Add arbitrary browser flags via [webBrowserFlags].
800 ///
801 /// Returns the browser manager, or throws an exception if
802 /// a connection fails to be established.
803 static Future<BrowserManager> start(
804 ChromiumLauncher chromiumLauncher,
805 Runtime runtime,
806 Uri url,
807 Future<WebSocketChannel> future, {
808 bool debug = false,
809 bool headless = true,
810 List<String> webBrowserFlags = const <String>[],
811 }) async {
812 final Chromium chrome = await chromiumLauncher.launch(
813 url.toString(),
814 headless: headless,
815 webBrowserFlags: webBrowserFlags,
816 );
817 final completer = Completer<BrowserManager>();
818
819 unawaited(
820 chrome.onExit
821 .then<Object?>((int? browserExitCode) {
822 throwToolExit('${runtime.name} exited with code $browserExitCode before connecting.');
823 })
824 .then(
825 (Object? obj) => obj,
826 onError: (Object error, StackTrace stackTrace) {
827 if (!completer.isCompleted) {
828 completer.completeError(error, stackTrace);
829 }
830 return null;
831 },
832 ),
833 );
834 unawaited(
835 future.then(
836 (WebSocketChannel webSocket) {
837 if (completer.isCompleted) {
838 return;
839 }
840 completer.complete(BrowserManager._(chrome, runtime, webSocket));
841 },
842 onError: (Object error, StackTrace stackTrace) {
843 chrome.close();
844 if (!completer.isCompleted) {
845 completer.completeError(error, stackTrace);
846 }
847 },
848 ),
849 );
850
851 return completer.future;
852 }
853
854 /// Loads [_BrowserEnvironment].
855 Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
856 return _BrowserEnvironment(
857 this,
858 null,
859 _browser.chromeConnection.url,
860 _onRestartController.stream,
861 );
862 }
863
864 /// Tells the browser to load a test suite from the URL [url].
865 ///
866 /// [url] should be an HTML page with a reference to the JS-compiled test
867 /// suite. [path] is the path of the original test suite file, which is used
868 /// for reporting. [suiteConfig] is the configuration for the test suite.
869 Future<RunnerSuite> load(
870 String path,
871 Uri url,
872 SuiteConfiguration suiteConfig,
873 Object message, {
874 Future<void> Function()? onDone,
875 }) async {
876 url = url.replace(
877 fragment: Uri.encodeFull(
878 jsonEncode(<String, Object>{
879 'metadata': suiteConfig.metadata.serialize(),
880 'browser': _runtime.identifier,
881 }),
882 ),
883 );
884
885 final int suiteID = _suiteID++;
886 RunnerSuiteController? controller;
887 void closeIframe() {
888 if (_closed) {
889 return;
890 }
891 _controllers.remove(controller);
892 _channel.sink.add(<String, Object>{'command': 'closeSuite', 'id': suiteID});
893 }
894
895 // The virtual channel will be closed when the suite is closed, in which
896 // case we should unload the iframe.
897 final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel();
898 final int suiteChannelID = virtualChannel.id;
899 final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
900 StreamTransformer<dynamic, dynamic>.fromHandlers(
901 handleDone: (EventSink<dynamic> sink) {
902 closeIframe();
903 sink.close();
904 onDone!();
905 },
906 ),
907 );
908
909 _channel.sink.add(<String, Object>{
910 'command': 'loadSuite',
911 'url': url.toString(),
912 'id': suiteID,
913 'channel': suiteChannelID,
914 });
915
916 try {
917 controller = deserializeSuite(
918 path,
919 SuitePlatform(Runtime.chrome),
920 suiteConfig,
921 await _environment,
922 suiteChannel,
923 message,
924 );
925
926 _controllers.add(controller);
927 return await controller.suite;
928 // Not limiting to catching Exception because the exception is rethrown.
929 } catch (_) {
930 closeIframe();
931 rethrow;
932 }
933 }
934
935 /// An implementation of [Environment.displayPause].
936 CancelableOperation<dynamic> _displayPause() {
937 if (_pauseCompleter != null) {
938 return _pauseCompleter!.operation;
939 }
940 _pauseCompleter = CancelableCompleter<dynamic>(
941 onCancel: () {
942 _channel.sink.add(<String, String>{'command': 'resume'});
943 _pauseCompleter = null;
944 },
945 );
946 _pauseCompleter!.operation.value.whenComplete(() {
947 _pauseCompleter = null;
948 });
949 _channel.sink.add(<String, String>{'command': 'displayPause'});
950
951 return _pauseCompleter!.operation;
952 }
953
954 /// The callback for handling messages received from the host page.
955 void _onMessage(dynamic message) {
956 assert(message is Map<String, dynamic>);
957 if (message is Map<String, dynamic>) {
958 switch (message['command'] as String?) {
959 case 'ping':
960 break;
961 case 'restart':
962 _onRestartController.add(null);
963 case 'resume':
964 if (_pauseCompleter != null) {
965 _pauseCompleter!.complete();
966 }
967 default:
968 // Unreachable.
969 assert(false);
970 }
971 }
972 }
973
974 /// Closes the manager and releases any resources it owns, including closing
975 /// the browser.
976 Future<dynamic> close() {
977 return _closeMemoizer.runOnce(() {
978 _closed = true;
979 _timer.cancel();
980 if (_pauseCompleter != null) {
981 _pauseCompleter!.complete();
982 }
983 _pauseCompleter = null;
984 _controllers.clear();
985 return _browser.close();
986 });
987 }
988}
989
990/// An implementation of [Environment] for the browser.
991///
992/// All methods forward directly to [BrowserManager].
993class _BrowserEnvironment implements Environment {
994 _BrowserEnvironment(this._manager, this.observatoryUrl, this.remoteDebuggerUrl, this.onRestart);
995
996 final BrowserManager _manager;
997
998 @override
999 final supportsDebugging = true;
1000
1001 // TODO(bkonyi): update package:test_core to no longer reference Observatory.
1002 @override
1003 final Uri? observatoryUrl;
1004
1005 @override
1006 final Uri remoteDebuggerUrl;
1007
1008 @override
1009 final Stream<dynamic> onRestart;
1010
1011 @override
1012 CancelableOperation<dynamic> displayPause() => _manager._displayPause();
1013}
1014