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 | |
5 | import 'dart:async'; |
6 | import 'dart:typed_data'; |
7 | |
8 | import 'package:async/async.dart'; |
9 | import 'package:http_multi_server/http_multi_server.dart'; |
10 | import 'package:mime/mime.dart'as mime; |
11 | import 'package:package_config/package_config.dart'; |
12 | import 'package:pool/pool.dart'; |
13 | import 'package:process/process.dart'; |
14 | import 'package:shelf/shelf.dart'as shelf; |
15 | import 'package:shelf/shelf_io.dart'as shelf_io; |
16 | import 'package:shelf_web_socket/shelf_web_socket.dart'; |
17 | import 'package:stream_channel/stream_channel.dart'; |
18 | import 'package:test_core/src/platform.dart'; // ignore: implementation_imports |
19 | import 'package:web_socket_channel/web_socket_channel.dart'; |
20 | import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'hide StackTrace; |
21 | |
22 | import '../artifacts.dart'; |
23 | import '../base/common.dart'; |
24 | import '../base/file_system.dart'; |
25 | import '../base/io.dart'; |
26 | import '../base/logger.dart'; |
27 | import '../build_info.dart'; |
28 | import '../cache.dart'; |
29 | import '../convert.dart'; |
30 | import '../dart/package_map.dart'; |
31 | import '../project.dart'; |
32 | import '../web/bootstrap.dart'; |
33 | import '../web/chrome.dart'; |
34 | import '../web/compile.dart'; |
35 | import '../web/memory_fs.dart'; |
36 | import 'flutter_web_goldens.dart'; |
37 | import 'test_compiler.dart'; |
38 | import 'test_time_recorder.dart'; |
39 | |
40 | shelf.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 | |
74 | class 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 | |
651 | class 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 | |
690 | class 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]. |
961 | class _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 |
Definitions
- createDirectoryHandler
- FlutterWebPlatform
- _
- defaultServerFactory
- start
- _closed
- _nullSafetyMode
- url
- _ahem
- _requireJs
- _ddcModuleLoaderJs
- _stackTraceMapper
- _flutterJs
- _dartSdk
- _dartSdkSourcemaps
- _canvasKitFile
- _handleTestRequest
- _handleStaticArtifact
- _packageFilesHandler
- _goldenFileHandler
- _localCanvasKitHandler
- _makeBuildConfigString
- _wrapperHandler
- load
- _launchBrowser
- closeEphemeral
- close
- OneOffHandler
- handler
- create
- _onRequest
- BrowserManager
- _
- start
- _loadBrowserEnvironment
- load
- closeIframe
- _displayPause
- _onMessage
- close
- _BrowserEnvironment
- _BrowserEnvironment
Learn more about Flutter for embedded and desktop on industrialflutter.com