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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com