1// Copyright 2019 The Chromium 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// ignore_for_file: implementation_imports 6 7import 'dart:async'; 8 9import 'package:async/async.dart'; 10import 'package:http_multi_server/http_multi_server.dart'; 11import 'package:path/path.dart' as p; // ignore: package_path_import 12import 'package:pool/pool.dart'; 13import 'package:shelf/shelf.dart' as shelf; 14import 'package:shelf/shelf_io.dart' as shelf_io; 15import 'package:shelf_packages_handler/shelf_packages_handler.dart'; 16import 'package:shelf_static/shelf_static.dart'; 17import 'package:shelf_web_socket/shelf_web_socket.dart'; 18import 'package:stream_channel/stream_channel.dart'; 19import 'package:test_api/backend.dart'; 20import 'package:test_api/src/backend/runtime.dart'; 21import 'package:test_api/src/backend/suite_platform.dart'; 22import 'package:test_api/src/util/stack_trace_mapper.dart'; 23import 'package:test_core/src/runner/configuration.dart'; 24import 'package:test_core/src/runner/environment.dart'; 25import 'package:test_core/src/runner/platform.dart'; 26import 'package:test_core/src/runner/plugin/platform_helpers.dart'; 27import 'package:test_core/src/runner/runner_suite.dart'; 28import 'package:test_core/src/runner/suite.dart'; 29import 'package:web_socket_channel/web_socket_channel.dart'; 30 31import '../artifacts.dart'; 32import '../base/common.dart'; 33import '../base/file_system.dart'; 34import '../cache.dart'; 35import '../convert.dart'; 36import '../dart/package_map.dart'; 37import '../globals.dart'; 38import '../web/chrome.dart'; 39 40class FlutterWebPlatform extends PlatformPlugin { 41 FlutterWebPlatform._(this._server, this._config, this._root) { 42 // Look up the location of the testing resources. 43 final Map<String, Uri> packageMap = PackageMap(fs.path.join( 44 Cache.flutterRoot, 45 'packages', 46 'flutter_tools', 47 '.packages', 48 )).map; 49 testUri = packageMap['test']; 50 final shelf.Cascade cascade = shelf.Cascade() 51 .add(_webSocketHandler.handler) 52 .add(packagesDirHandler()) 53 .add(_jsHandler.handler) 54 .add(createStaticHandler( 55 fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'), 56 serveFilesOutsidePath: true, 57 )) 58 .add(createStaticHandler(_config.suiteDefaults.precompiledPath, 59 serveFilesOutsidePath: true)) 60 .add(_handleStaticArtifact) 61 .add(_wrapperHandler); 62 _server.mount(cascade.handler); 63 } 64 65 static Future<FlutterWebPlatform> start(String root) async { 66 final shelf_io.IOServer server = 67 shelf_io.IOServer(await HttpMultiServer.loopback(0)); 68 return FlutterWebPlatform._( 69 server, 70 Configuration.current, 71 root, 72 ); 73 } 74 75 Uri testUri; 76 77 /// The test runner configuration. 78 final Configuration _config; 79 80 /// The underlying server. 81 final shelf.Server _server; 82 83 /// The URL for this server. 84 Uri get url => _server.url; 85 86 /// The ahem text file. 87 File get ahem => fs.file(fs.path.join( 88 Cache.flutterRoot, 89 'packages', 90 'flutter_tools', 91 'static', 92 'Ahem.ttf', 93 )); 94 95 /// The require js binary. 96 File get requireJs => fs.file(fs.path.join( 97 artifacts.getArtifactPath(Artifact.engineDartSdkPath), 98 'lib', 99 'dev_compiler', 100 'amd', 101 'require.js', 102 )); 103 104 /// The ddc to dart stack trace mapper. 105 File get stackTraceMapper => fs.file(fs.path.join( 106 artifacts.getArtifactPath(Artifact.engineDartSdkPath), 107 'lib', 108 'dev_compiler', 109 'web', 110 'dart_stack_trace_mapper.js', 111 )); 112 113 /// The precompiled dart sdk. 114 File get dartSdk => fs.file(fs.path.join( 115 artifacts.getArtifactPath(Artifact.flutterWebSdk), 116 'kernel', 117 'amd', 118 'dart_sdk.js', 119 )); 120 121 /// The precompiled test javascript. 122 File get testDartJs => fs.file(fs.path.join( 123 testUri.toFilePath(), 124 'dart.js', 125 )); 126 127 File get testHostDartJs => fs.file(fs.path.join( 128 testUri.toFilePath(), 129 'src', 130 'runner', 131 'browser', 132 'static', 133 'host.dart.js', 134 )); 135 136 Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async { 137 if (request.requestedUri.path.contains('require.js')) { 138 return shelf.Response.ok( 139 requireJs.openRead(), 140 headers: <String, String>{'Content-Type': 'text/javascript'}, 141 ); 142 } else if (request.requestedUri.path.contains('Ahem.ttf')) { 143 return shelf.Response.ok(ahem.openRead()); 144 } else if (request.requestedUri.path.contains('dart_sdk.js')) { 145 return shelf.Response.ok( 146 dartSdk.openRead(), 147 headers: <String, String>{'Content-Type': 'text/javascript'}, 148 ); 149 } else if (request.requestedUri.path 150 .contains('stack_trace_mapper.dart.js')) { 151 return shelf.Response.ok( 152 stackTraceMapper.openRead(), 153 headers: <String, String>{'Content-Type': 'text/javascript'}, 154 ); 155 } else if (request.requestedUri.path.contains('static/dart.js')) { 156 return shelf.Response.ok( 157 testDartJs.openRead(), 158 headers: <String, String>{'Content-Type': 'text/javascript'}, 159 ); 160 } else if (request.requestedUri.path.contains('host.dart.js')) { 161 return shelf.Response.ok( 162 testHostDartJs.openRead(), 163 headers: <String, String>{'Content-Type': 'text/javascript'}, 164 ); 165 } else { 166 return shelf.Response.notFound('Not Found'); 167 } 168 } 169 170 final OneOffHandler _webSocketHandler = OneOffHandler(); 171 final PathHandler _jsHandler = PathHandler(); 172 final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>(); 173 final String _root; 174 175 bool get _closed => _closeMemo.hasRun; 176 177 // A map from browser identifiers to futures that will complete to the 178 // [BrowserManager]s for those browsers, or `null` if they failed to load. 179 final Map<Runtime, Future<BrowserManager>> _browserManagers = 180 <Runtime, Future<BrowserManager>>{}; 181 182 // Mappers for Dartifying stack traces, indexed by test path. 183 final Map<String, StackTraceMapper> _mappers = <String, StackTraceMapper>{}; 184 185 // A handler that serves wrapper files used to bootstrap tests. 186 shelf.Response _wrapperHandler(shelf.Request request) { 187 final String path = fs.path.fromUri(request.url); 188 if (path.endsWith('.html')) { 189 final String test = fs.path.withoutExtension(path) + '.dart'; 190 final String scriptBase = htmlEscape.convert(fs.path.basename(test)); 191 final String link = '<link rel="x-dart-test" href="$scriptBase">'; 192 return shelf.Response.ok(''' 193 <!DOCTYPE html> 194 <html> 195 <head> 196 <title>${htmlEscape.convert(test)} Test</title> 197 $link 198 <script src="static/dart.js"></script> 199 </head> 200 </html> 201 ''', headers: <String, String>{'Content-Type': 'text/html'}); 202 } 203 printTrace('Did not find anything for request: ${request.url}'); 204 return shelf.Response.notFound('Not found.'); 205 } 206 207 @override 208 Future<RunnerSuite> load(String path, SuitePlatform platform, 209 SuiteConfiguration suiteConfig, Object message) async { 210 if (_closed) { 211 return null; 212 } 213 final Runtime browser = platform.runtime; 214 final BrowserManager browserManager = await _browserManagerFor(browser); 215 if (_closed || browserManager == null) { 216 return null; 217 } 218 219 final Uri suiteUrl = url.resolveUri(fs.path.toUri(fs.path.withoutExtension( 220 fs.path.relative(path, from: fs.path.join(_root, 'test'))) + 221 '.html')); 222 final RunnerSuite suite = await browserManager 223 .load(path, suiteUrl, suiteConfig, message, mapper: _mappers[path]); 224 if (_closed) { 225 return null; 226 } 227 return suite; 228 } 229 230 @override 231 StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) => 232 throw UnimplementedError(); 233 234 /// Returns the [BrowserManager] for [runtime], which should be a browser. 235 /// 236 /// If no browser manager is running yet, starts one. 237 Future<BrowserManager> _browserManagerFor(Runtime browser) { 238 final Future<BrowserManager> managerFuture = _browserManagers[browser]; 239 if (managerFuture != null) { 240 return managerFuture; 241 } 242 final Completer<WebSocketChannel> completer = 243 Completer<WebSocketChannel>.sync(); 244 final String path = 245 _webSocketHandler.create(webSocketHandler(completer.complete)); 246 final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path); 247 final Uri hostUrl = url 248 .resolve('static/index.html') 249 .replace(queryParameters: <String, String>{ 250 'managerUrl': webSocketUrl.toString(), 251 'debug': _config.pauseAfterLoad.toString() 252 }); 253 254 printTrace('Serving tests at $hostUrl'); 255 256 final Future<BrowserManager> future = BrowserManager.start( 257 browser, 258 hostUrl, 259 completer.future, 260 ); 261 262 // Store null values for browsers that error out so we know not to load them 263 // again. 264 _browserManagers[browser] = future.catchError((dynamic _) => null); 265 266 return future; 267 } 268 269 @override 270 Future<void> closeEphemeral() { 271 final List<Future<BrowserManager>> managers = 272 _browserManagers.values.toList(); 273 _browserManagers.clear(); 274 return Future.wait(managers.map((Future<BrowserManager> manager) async { 275 final BrowserManager result = await manager; 276 if (result == null) { 277 return; 278 } 279 await result.close(); 280 })); 281 } 282 283 @override 284 Future<void> close() => _closeMemo.runOnce(() async { 285 final List<Future<dynamic>> futures = _browserManagers.values 286 .map<Future<dynamic>>((Future<BrowserManager> future) async { 287 final BrowserManager result = await future; 288 if (result == null) { 289 return; 290 } 291 await result.close(); 292 }).toList(); 293 futures.add(_server.close()); 294 await Future.wait<void>(futures); 295 }); 296} 297 298class OneOffHandler { 299 /// A map from URL paths to handlers. 300 final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{}; 301 302 /// The counter of handlers that have been activated. 303 int _counter = 0; 304 305 /// The actual [shelf.Handler] that dispatches requests. 306 shelf.Handler get handler => _onRequest; 307 308 /// Creates a new one-off handler that forwards to [handler]. 309 /// 310 /// Returns a string that's the URL path for hitting this handler, relative to 311 /// the URL for the one-off handler itself. 312 /// 313 /// [handler] will be unmounted as soon as it receives a request. 314 String create(shelf.Handler handler) { 315 final String path = _counter.toString(); 316 _handlers[path] = handler; 317 _counter++; 318 return path; 319 } 320 321 /// Dispatches [request] to the appropriate handler. 322 FutureOr<shelf.Response> _onRequest(shelf.Request request) { 323 final List<String> components = p.url.split(request.url.path); 324 if (components.isEmpty) { 325 return shelf.Response.notFound(null); 326 } 327 final String path = components.removeAt(0); 328 final FutureOr<shelf.Response> Function(shelf.Request) handler = 329 _handlers.remove(path); 330 if (handler == null) { 331 return shelf.Response.notFound(null); 332 } 333 return handler(request.change(path: path)); 334 } 335} 336 337class PathHandler { 338 /// A trie of path components to handlers. 339 final _Node _paths = _Node(); 340 341 /// The shelf handler. 342 shelf.Handler get handler => _onRequest; 343 344 /// Returns middleware that nests all requests beneath the URL prefix 345 /// [beneath]. 346 static shelf.Middleware nestedIn(String beneath) { 347 return (FutureOr<shelf.Response> Function(shelf.Request) handler) { 348 final PathHandler pathHandler = PathHandler()..add(beneath, handler); 349 return pathHandler.handler; 350 }; 351 } 352 353 /// Routes requests at or under [path] to [handler]. 354 /// 355 /// If [path] is a parent or child directory of another path in this handler, 356 /// the longest matching prefix wins. 357 void add(String path, shelf.Handler handler) { 358 _Node node = _paths; 359 for (String component in p.url.split(path)) { 360 node = node.children.putIfAbsent(component, () => _Node()); 361 } 362 node.handler = handler; 363 } 364 365 FutureOr<shelf.Response> _onRequest(shelf.Request request) { 366 shelf.Handler handler; 367 int handlerIndex; 368 _Node node = _paths; 369 final List<String> components = p.url.split(request.url.path); 370 for (int i = 0; i < components.length; i++) { 371 node = node.children[components[i]]; 372 if (node == null) { 373 break; 374 } 375 if (node.handler == null) { 376 continue; 377 } 378 handler = node.handler; 379 handlerIndex = i; 380 } 381 382 if (handler == null) { 383 return shelf.Response.notFound('Not found.'); 384 } 385 386 return handler( 387 request.change(path: p.url.joinAll(components.take(handlerIndex + 1)))); 388 } 389} 390 391/// A trie node. 392class _Node { 393 shelf.Handler handler; 394 final Map<String, _Node> children = <String, _Node>{}; 395} 396 397class BrowserManager { 398 /// Creates a new BrowserManager that communicates with [browser] over 399 /// [webSocket]. 400 BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) { 401 // The duration should be short enough that the debugging console is open as 402 // soon as the user is done setting breakpoints, but long enough that a test 403 // doing a lot of synchronous work doesn't trigger a false positive. 404 // 405 // Start this canceled because we don't want it to start ticking until we 406 // get some response from the iframe. 407 _timer = RestartableTimer(const Duration(seconds: 3), () { 408 for (RunnerSuiteController controller in _controllers) { 409 controller.setDebugging(true); 410 } 411 }) 412 ..cancel(); 413 414 // Whenever we get a message, no matter which child channel it's for, we the 415 // know browser is still running code which means the user isn't debugging. 416 _channel = MultiChannel<dynamic>( 417 webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) { 418 return stream.map((Object message) { 419 if (!_closed) { 420 _timer.reset(); 421 } 422 for (RunnerSuiteController controller in _controllers) { 423 controller.setDebugging(false); 424 } 425 426 return message; 427 }); 428 })); 429 430 _environment = _loadBrowserEnvironment(); 431 _channel.stream.listen(_onMessage, onDone: close); 432 } 433 434 /// The browser instance that this is connected to via [_channel]. 435 final Chrome _browser; 436 437 // TODO(nweiz): Consider removing the duplication between this and 438 // [_browser.name]. 439 /// The [Runtime] for [_browser]. 440 final Runtime _runtime; 441 442 /// The channel used to communicate with the browser. 443 /// 444 /// This is connected to a page running `static/host.dart`. 445 MultiChannel<dynamic> _channel; 446 447 /// A pool that ensures that limits the number of initial connections the 448 /// manager will wait for at once. 449 /// 450 /// This isn't the *total* number of connections; any number of iframes may be 451 /// loaded in the same browser. However, the browser can only load so many at 452 /// once, and we want a timeout in case they fail so we only wait for so many 453 /// at once. 454 final Pool _pool = Pool(8); 455 456 /// The ID of the next suite to be loaded. 457 /// 458 /// This is used to ensure that the suites can be referred to consistently 459 /// across the client and server. 460 int _suiteID = 0; 461 462 /// Whether the channel to the browser has closed. 463 bool _closed = false; 464 465 /// The completer for [_BrowserEnvironment.displayPause]. 466 /// 467 /// This will be `null` as long as the browser isn't displaying a pause 468 /// screen. 469 CancelableCompleter<dynamic> _pauseCompleter; 470 471 /// The controller for [_BrowserEnvironment.onRestart]. 472 final StreamController<dynamic> _onRestartController = 473 StreamController<dynamic>.broadcast(); 474 475 /// The environment to attach to each suite. 476 Future<_BrowserEnvironment> _environment; 477 478 /// Controllers for every suite in this browser. 479 /// 480 /// These are used to mark suites as debugging or not based on the browser's 481 /// pings. 482 final Set<RunnerSuiteController> _controllers = <RunnerSuiteController>{}; 483 484 // A timer that's reset whenever we receive a message from the browser. 485 // 486 // Because the browser stops running code when the user is actively debugging, 487 // this lets us detect whether they're debugging reasonably accurately. 488 RestartableTimer _timer; 489 490 final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>(); 491 492 /// Starts the browser identified by [runtime] and has it connect to [url]. 493 /// 494 /// [url] should serve a page that establishes a WebSocket connection with 495 /// this process. That connection, once established, should be emitted via 496 /// [future]. If [debug] is true, starts the browser in debug mode, with its 497 /// debugger interfaces on and detected. 498 /// 499 /// The [settings] indicate how to invoke this browser's executable. 500 /// 501 /// Returns the browser manager, or throws an [ApplicationException] if a 502 /// connection fails to be established. 503 static Future<BrowserManager> start( 504 Runtime runtime, Uri url, Future<WebSocketChannel> future, 505 {bool debug = false}) async { 506 final Chrome chrome = 507 await chromeLauncher.launch(url.toString(), headless: true); 508 509 final Completer<BrowserManager> completer = Completer<BrowserManager>(); 510 511 unawaited(chrome.onExit.then((void _) { 512 throwToolExit('${runtime.name} exited before connecting.'); 513 }).catchError((dynamic error, StackTrace stackTrace) { 514 if (completer.isCompleted) { 515 return; 516 } 517 completer.completeError(error, stackTrace); 518 })); 519 unawaited(future.then((WebSocketChannel webSocket) { 520 if (completer.isCompleted) { 521 return; 522 } 523 completer.complete(BrowserManager._(chrome, runtime, webSocket)); 524 }).catchError((dynamic error, StackTrace stackTrace) { 525 chrome.close(); 526 if (completer.isCompleted) { 527 return; 528 } 529 completer.completeError(error, stackTrace); 530 })); 531 532 return completer.future.timeout(const Duration(seconds: 30), onTimeout: () { 533 chrome.close(); 534 throwToolExit('Timed out waiting for ${runtime.name} to connect.'); 535 return; 536 }); 537 } 538 539 /// Loads [_BrowserEnvironment]. 540 Future<_BrowserEnvironment> _loadBrowserEnvironment() async { 541 return _BrowserEnvironment( 542 this, null, _browser.remoteDebuggerUri, _onRestartController.stream); 543 } 544 545 /// Tells the browser the load a test suite from the URL [url]. 546 /// 547 /// [url] should be an HTML page with a reference to the JS-compiled test 548 /// suite. [path] is the path of the original test suite file, which is used 549 /// for reporting. [suiteConfig] is the configuration for the test suite. 550 /// 551 /// If [mapper] is passed, it's used to map stack traces for errors coming 552 /// from this test suite. 553 Future<RunnerSuite> load( 554 String path, Uri url, SuiteConfiguration suiteConfig, Object message, 555 {StackTraceMapper mapper}) async { 556 url = url.replace( 557 fragment: Uri.encodeFull(jsonEncode(<String, Object>{ 558 'metadata': suiteConfig.metadata.serialize(), 559 'browser': _runtime.identifier 560 }))); 561 562 final int suiteID = _suiteID++; 563 RunnerSuiteController controller; 564 void closeIframe() { 565 if (_closed) { 566 return; 567 } 568 _controllers.remove(controller); 569 _channel.sink 570 .add(<String, Object>{'command': 'closeSuite', 'id': suiteID}); 571 } 572 573 // The virtual channel will be closed when the suite is closed, in which 574 // case we should unload the iframe. 575 final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel(); 576 final int suiteChannelID = virtualChannel.id; 577 final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream( 578 StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) { 579 closeIframe(); 580 sink.close(); 581 })); 582 583 return await _pool.withResource<RunnerSuite>(() async { 584 _channel.sink.add(<String, Object>{ 585 'command': 'loadSuite', 586 'url': url.toString(), 587 'id': suiteID, 588 'channel': suiteChannelID 589 }); 590 591 try { 592 controller = deserializeSuite(path, SuitePlatform(Runtime.chrome), 593 suiteConfig, await _environment, suiteChannel, message); 594 controller.channel('test.browser.mapper').sink.add(mapper?.serialize()); 595 596 _controllers.add(controller); 597 return await controller.suite; 598 } catch (_) { 599 closeIframe(); 600 rethrow; 601 } 602 }); 603 } 604 605 /// An implementation of [Environment.displayPause]. 606 CancelableOperation<dynamic> _displayPause() { 607 if (_pauseCompleter != null) { 608 return _pauseCompleter.operation; 609 } 610 _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () { 611 _channel.sink.add(<String, String>{'command': 'resume'}); 612 _pauseCompleter = null; 613 }); 614 _pauseCompleter.operation.value.whenComplete(() { 615 _pauseCompleter = null; 616 }); 617 _channel.sink.add(<String, String>{'command': 'displayPause'}); 618 619 return _pauseCompleter.operation; 620 } 621 622 /// The callback for handling messages received from the host page. 623 void _onMessage(dynamic message) { 624 switch (message['command'] as String) { 625 case 'ping': 626 break; 627 case 'restart': 628 _onRestartController.add(null); 629 break; 630 case 'resume': 631 if (_pauseCompleter != null) { 632 _pauseCompleter.complete(); 633 } 634 break; 635 default: 636 // Unreachable. 637 assert(false); 638 break; 639 } 640 } 641 642 /// Closes the manager and releases any resources it owns, including closing 643 /// the browser. 644 Future<dynamic> close() { 645 return _closeMemoizer.runOnce(() { 646 _closed = true; 647 _timer.cancel(); 648 if (_pauseCompleter != null) { 649 _pauseCompleter.complete(); 650 } 651 _pauseCompleter = null; 652 _controllers.clear(); 653 return _browser.close(); 654 }); 655 } 656} 657 658/// An implementation of [Environment] for the browser. 659/// 660/// All methods forward directly to [BrowserManager]. 661class _BrowserEnvironment implements Environment { 662 _BrowserEnvironment(this._manager, this.observatoryUrl, 663 this.remoteDebuggerUrl, this.onRestart); 664 665 final BrowserManager _manager; 666 667 @override 668 final bool supportsDebugging = true; 669 670 @override 671 final Uri observatoryUrl; 672 673 @override 674 final Uri remoteDebuggerUrl; 675 676 @override 677 final Stream<dynamic> onRestart; 678 679 @override 680 CancelableOperation<dynamic> displayPause() => _manager._displayPause(); 681} 682