| OLD | NEW |
| (Empty) |
| 1 // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file | |
| 2 // for details. All rights reserved. Use of this source code is governed by a | |
| 3 // BSD-style license that can be found in the LICENSE file. | |
| 4 | |
| 5 import 'dart:async'; | |
| 6 import 'dart:convert'; | |
| 7 import 'dart:io'; | |
| 8 | |
| 9 import 'package:async/async.dart'; | |
| 10 import 'package:http_multi_server/http_multi_server.dart'; | |
| 11 import 'package:path/path.dart' as p; | |
| 12 import 'package:pool/pool.dart'; | |
| 13 import 'package:shelf/shelf.dart' as shelf; | |
| 14 import 'package:shelf/shelf_io.dart' as shelf_io; | |
| 15 import 'package:shelf_static/shelf_static.dart'; | |
| 16 import 'package:shelf_web_socket/shelf_web_socket.dart'; | |
| 17 | |
| 18 import '../../backend/metadata.dart'; | |
| 19 import '../../backend/suite.dart'; | |
| 20 import '../../backend/test_platform.dart'; | |
| 21 import '../../util/io.dart'; | |
| 22 import '../../util/one_off_handler.dart'; | |
| 23 import '../../util/path_handler.dart'; | |
| 24 import '../../util/stack_trace_mapper.dart'; | |
| 25 import '../../utils.dart'; | |
| 26 import '../configuration.dart'; | |
| 27 import '../load_exception.dart'; | |
| 28 import 'browser_manager.dart'; | |
| 29 import 'compiler_pool.dart'; | |
| 30 import 'polymer.dart'; | |
| 31 | |
| 32 /// A server that serves JS-compiled tests to browsers. | |
| 33 /// | |
| 34 /// A test suite may be loaded for a given file using [loadSuite]. | |
| 35 class BrowserServer { | |
| 36 /// Starts the server. | |
| 37 /// | |
| 38 /// [root] is the root directory that the server should serve. It defaults to | |
| 39 /// the working directory. | |
| 40 static Future<BrowserServer> start(Configuration config, {String root}) | |
| 41 async { | |
| 42 var server = new shelf_io.IOServer(await HttpMultiServer.loopback(0)); | |
| 43 return new BrowserServer(server, config, root: root); | |
| 44 } | |
| 45 | |
| 46 /// The underlying server. | |
| 47 final shelf.Server _server; | |
| 48 | |
| 49 /// A randomly-generated secret. | |
| 50 /// | |
| 51 /// This is used to ensure that other users on the same system can't snoop | |
| 52 /// on data being served through this server. | |
| 53 final _secret = randomBase64(24, urlSafe: true); | |
| 54 | |
| 55 /// The URL for this server. | |
| 56 Uri get url => _server.url.resolve(_secret + "/"); | |
| 57 | |
| 58 /// The test runner configuration. | |
| 59 Configuration _config; | |
| 60 | |
| 61 /// A [OneOffHandler] for servicing WebSocket connections for | |
| 62 /// [BrowserManager]s. | |
| 63 /// | |
| 64 /// This is one-off because each [BrowserManager] can only connect to a single | |
| 65 /// WebSocket, | |
| 66 final _webSocketHandler = new OneOffHandler(); | |
| 67 | |
| 68 /// A [PathHandler] used to serve compiled JS. | |
| 69 final _jsHandler = new PathHandler(); | |
| 70 | |
| 71 /// The [CompilerPool] managing active instances of `dart2js`. | |
| 72 /// | |
| 73 /// This is `null` if tests are loaded from `pub serve`. | |
| 74 final CompilerPool _compilers; | |
| 75 | |
| 76 /// The temporary directory in which compiled JS is emitted. | |
| 77 final String _compiledDir; | |
| 78 | |
| 79 /// The root directory served statically by this server. | |
| 80 final String _root; | |
| 81 | |
| 82 /// The pool of active `pub serve` compilations. | |
| 83 /// | |
| 84 /// Pub itself ensures that only one compilation runs at a time; we just use | |
| 85 /// this pool to make sure that the output is nice and linear. | |
| 86 final _pubServePool = new Pool(1); | |
| 87 | |
| 88 /// The HTTP client to use when caching JS files in `pub serve`. | |
| 89 final HttpClient _http; | |
| 90 | |
| 91 /// Whether [close] has been called. | |
| 92 bool get _closed => _closeMemo.hasRun; | |
| 93 | |
| 94 /// The memoizer for running [close] exactly once. | |
| 95 final _closeMemo = new AsyncMemoizer(); | |
| 96 | |
| 97 /// A map from browser identifiers to futures that will complete to the | |
| 98 /// [BrowserManager]s for those browsers, or the errors that occurred when | |
| 99 /// trying to load those managers. | |
| 100 /// | |
| 101 /// This should only be accessed through [_browserManagerFor]. | |
| 102 final _browserManagers = | |
| 103 new Map<TestPlatform, Future<Result<BrowserManager>>>(); | |
| 104 | |
| 105 /// A map from test suite paths to Futures that will complete once those | |
| 106 /// suites are finished compiling. | |
| 107 /// | |
| 108 /// This is used to make sure that a given test suite is only compiled once | |
| 109 /// per run, rather than once per browser per run. | |
| 110 final _compileFutures = new Map<String, Future>(); | |
| 111 | |
| 112 final _mappers = new Map<String, StackTraceMapper>(); | |
| 113 | |
| 114 BrowserServer(this._server, Configuration config, {String root}) | |
| 115 : _root = root == null ? p.current : root, | |
| 116 _config = config, | |
| 117 _compiledDir = config.pubServeUrl == null ? createTempDir() : null, | |
| 118 _http = config.pubServeUrl == null ? null : new HttpClient(), | |
| 119 _compilers = new CompilerPool(color: config.color) { | |
| 120 var cascade = new shelf.Cascade() | |
| 121 .add(_webSocketHandler.handler); | |
| 122 | |
| 123 if (_config.pubServeUrl == null) { | |
| 124 cascade = cascade | |
| 125 .add(_createPackagesHandler()) | |
| 126 .add(_jsHandler.handler) | |
| 127 .add(createStaticHandler(_root)) | |
| 128 .add(_wrapperHandler); | |
| 129 } | |
| 130 | |
| 131 var pipeline = new shelf.Pipeline() | |
| 132 .addMiddleware(nestingMiddleware(_secret)) | |
| 133 .addHandler(cascade.handler); | |
| 134 | |
| 135 _server.mount(pipeline); | |
| 136 } | |
| 137 | |
| 138 /// Returns a handler that serves the contents of the "packages/" directory | |
| 139 /// for any URL that contains "packages/". | |
| 140 /// | |
| 141 /// This is a factory so it can wrap a static handler. | |
| 142 shelf.Handler _createPackagesHandler() { | |
| 143 var staticHandler = | |
| 144 createStaticHandler(_config.packageRoot, serveFilesOutsidePath: true); | |
| 145 | |
| 146 return (request) { | |
| 147 var segments = p.url.split(request.url.path); | |
| 148 | |
| 149 for (var i = 0; i < segments.length; i++) { | |
| 150 if (segments[i] != "packages") continue; | |
| 151 return staticHandler( | |
| 152 request.change(path: p.url.joinAll(segments.take(i + 1)))); | |
| 153 } | |
| 154 | |
| 155 return new shelf.Response.notFound("Not found."); | |
| 156 }; | |
| 157 } | |
| 158 | |
| 159 /// A handler that serves wrapper files used to bootstrap tests. | |
| 160 shelf.Response _wrapperHandler(shelf.Request request) { | |
| 161 var path = p.fromUri(request.url); | |
| 162 | |
| 163 if (path.endsWith(".browser_test.dart")) { | |
| 164 return new shelf.Response.ok(''' | |
| 165 import "package:test/src/runner/browser/iframe_listener.dart"; | |
| 166 | |
| 167 import "${p.basename(p.withoutExtension(p.withoutExtension(path)))}" as test; | |
| 168 | |
| 169 void main() { | |
| 170 IframeListener.start(() => test.main); | |
| 171 } | |
| 172 ''', headers: {'Content-Type': 'application/dart'}); | |
| 173 } | |
| 174 | |
| 175 if (path.endsWith(".html")) { | |
| 176 var test = p.withoutExtension(path) + ".dart"; | |
| 177 | |
| 178 // Link to the Dart wrapper on Dartium and the compiled JS version | |
| 179 // elsewhere. | |
| 180 var scriptBase = | |
| 181 "${HTML_ESCAPE.convert(p.basename(test))}.browser_test.dart"; | |
| 182 var script = request.headers['user-agent'].contains('(Dart)') | |
| 183 ? 'type="application/dart" src="$scriptBase"' | |
| 184 : 'src="$scriptBase.js"'; | |
| 185 | |
| 186 return new shelf.Response.ok(''' | |
| 187 <!DOCTYPE html> | |
| 188 <html> | |
| 189 <head> | |
| 190 <title>${HTML_ESCAPE.convert(test)} Test</title> | |
| 191 <script $script></script> | |
| 192 </head> | |
| 193 </html> | |
| 194 ''', headers: {'Content-Type': 'text/html'}); | |
| 195 } | |
| 196 | |
| 197 return new shelf.Response.notFound('Not found.'); | |
| 198 } | |
| 199 | |
| 200 /// Loads the test suite at [path] on the browser [browser]. | |
| 201 /// | |
| 202 /// This will start a browser to load the suite if one isn't already running. | |
| 203 /// Throws an [ArgumentError] if [browser] isn't a browser platform. | |
| 204 Future<Suite> loadSuite(String path, TestPlatform browser, | |
| 205 Metadata metadata) async { | |
| 206 if (!browser.isBrowser) { | |
| 207 throw new ArgumentError("$browser is not a browser."); | |
| 208 } | |
| 209 | |
| 210 var htmlPath = p.withoutExtension(path) + '.html'; | |
| 211 if (new File(htmlPath).existsSync() && | |
| 212 !new File(htmlPath).readAsStringSync() | |
| 213 .contains('packages/test/dart.js')) { | |
| 214 throw new LoadException( | |
| 215 path, | |
| 216 '"${htmlPath}" must contain <script src="packages/test/dart.js">' | |
| 217 '</script>.'); | |
| 218 } | |
| 219 | |
| 220 var suiteUrl; | |
| 221 if (_config.pubServeUrl != null) { | |
| 222 var suitePrefix = p.toUri(p.withoutExtension( | |
| 223 p.relative(path, from: p.join(_root, 'test')))).path; | |
| 224 | |
| 225 var dartUrl; | |
| 226 // Polymer generates a bootstrap entrypoint that wraps the entrypoint we | |
| 227 // see on disk, and modifies the HTML file to point to the bootstrap | |
| 228 // instead. To make sure we get the right source maps and wait for the | |
| 229 // right file to compile, we have some Polymer-specific logic here to load | |
| 230 // the boostrap instead of the unwrapped file. | |
| 231 if (isPolymerEntrypoint(path)) { | |
| 232 dartUrl = _config.pubServeUrl.resolve( | |
| 233 "$suitePrefix.html.polymer.bootstrap.dart.browser_test.dart"); | |
| 234 } else { | |
| 235 dartUrl = _config.pubServeUrl.resolve( | |
| 236 '$suitePrefix.dart.browser_test.dart'); | |
| 237 } | |
| 238 | |
| 239 await _pubServeSuite(path, dartUrl, browser); | |
| 240 suiteUrl = _config.pubServeUrl.resolveUri(p.toUri('$suitePrefix.html')); | |
| 241 } else { | |
| 242 if (browser.isJS) await _compileSuite(path); | |
| 243 if (_closed) return null; | |
| 244 suiteUrl = url.resolveUri(p.toUri( | |
| 245 p.withoutExtension(p.relative(path, from: _root)) + ".html")); | |
| 246 } | |
| 247 | |
| 248 if (_closed) return null; | |
| 249 | |
| 250 // TODO(nweiz): Don't start the browser until all the suites are compiled. | |
| 251 var browserManager = await _browserManagerFor(browser); | |
| 252 if (_closed) return null; | |
| 253 | |
| 254 var suite = await browserManager.loadSuite(path, suiteUrl, metadata, | |
| 255 mapper: browser.isJS ? _mappers[path] : null); | |
| 256 if (_closed) return null; | |
| 257 return suite; | |
| 258 } | |
| 259 | |
| 260 /// Loads a test suite at [path] from the `pub serve` URL [dartUrl]. | |
| 261 /// | |
| 262 /// This ensures that only one suite is loaded at a time, and that any errors | |
| 263 /// are exposed as [LoadException]s. | |
| 264 Future _pubServeSuite(String path, Uri dartUrl, TestPlatform browser) { | |
| 265 return _pubServePool.withResource(() async { | |
| 266 var timer = new Timer(new Duration(seconds: 1), () { | |
| 267 print('"pub serve" is compiling $path...'); | |
| 268 }); | |
| 269 | |
| 270 // For browsers that run Dart compiled to JavaScript, get the source map | |
| 271 // instead of the Dart code for two reasons. We want to verify that the | |
| 272 // server's dart2js compiler is running on the Dart code, and also load | |
| 273 // the StackTraceMapper. | |
| 274 var getSourceMap = browser.isJS; | |
| 275 | |
| 276 var url = getSourceMap | |
| 277 ? dartUrl.replace(path: dartUrl.path + '.js.map') | |
| 278 : dartUrl; | |
| 279 | |
| 280 var response; | |
| 281 try { | |
| 282 var request = await _http.getUrl(url); | |
| 283 response = await request.close(); | |
| 284 | |
| 285 if (response.statusCode != 200) { | |
| 286 // We don't care about the response body, but we have to drain it or | |
| 287 // else the process can't exit. | |
| 288 response.listen((_) {}); | |
| 289 | |
| 290 throw new LoadException(path, | |
| 291 "Error getting $url: ${response.statusCode} " | |
| 292 "${response.reasonPhrase}\n" | |
| 293 'Make sure "pub serve" is serving the test/ directory.'); | |
| 294 } | |
| 295 | |
| 296 if (getSourceMap && !_config.jsTrace) { | |
| 297 _mappers[path] = new StackTraceMapper( | |
| 298 await UTF8.decodeStream(response), | |
| 299 mapUrl: url, | |
| 300 packageRoot: _config.pubServeUrl.resolve('packages'), | |
| 301 sdkRoot: _config.pubServeUrl.resolve('packages/\$sdk')); | |
| 302 return; | |
| 303 } | |
| 304 | |
| 305 // Drain the response stream. | |
| 306 response.listen((_) {}); | |
| 307 } on IOException catch (error) { | |
| 308 var message = getErrorMessage(error); | |
| 309 if (error is SocketException) { | |
| 310 message = "${error.osError.message} " | |
| 311 "(errno ${error.osError.errorCode})"; | |
| 312 } | |
| 313 | |
| 314 throw new LoadException(path, | |
| 315 "Error getting $url: $message\n" | |
| 316 'Make sure "pub serve" is running.'); | |
| 317 } finally { | |
| 318 timer.cancel(); | |
| 319 } | |
| 320 }); | |
| 321 } | |
| 322 | |
| 323 /// Compile the test suite at [dartPath] to JavaScript. | |
| 324 /// | |
| 325 /// Once the suite has been compiled, it's added to [_jsHandler] so it can be | |
| 326 /// served. | |
| 327 Future _compileSuite(String dartPath) { | |
| 328 return _compileFutures.putIfAbsent(dartPath, () async { | |
| 329 var dir = new Directory(_compiledDir).createTempSync('test_').path; | |
| 330 var jsPath = p.join(dir, p.basename(dartPath) + ".browser_test.dart.js"); | |
| 331 | |
| 332 await _compilers.compile(dartPath, jsPath, | |
| 333 packageRoot: _config.packageRoot); | |
| 334 if (_closed) return; | |
| 335 | |
| 336 var jsUrl = p.toUri(p.relative(dartPath, from: _root)).path + | |
| 337 '.browser_test.dart.js'; | |
| 338 _jsHandler.add(jsUrl, (request) { | |
| 339 return new shelf.Response.ok(new File(jsPath).readAsStringSync(), | |
| 340 headers: {'Content-Type': 'application/javascript'}); | |
| 341 }); | |
| 342 | |
| 343 var mapUrl = p.toUri(p.relative(dartPath, from: _root)).path + | |
| 344 '.browser_test.dart.js.map'; | |
| 345 _jsHandler.add(mapUrl, (request) { | |
| 346 return new shelf.Response.ok( | |
| 347 new File(jsPath + '.map').readAsStringSync(), | |
| 348 headers: {'Content-Type': 'application/json'}); | |
| 349 }); | |
| 350 | |
| 351 if (_config.jsTrace) return; | |
| 352 var mapPath = jsPath + '.map'; | |
| 353 _mappers[dartPath] = new StackTraceMapper( | |
| 354 new File(mapPath).readAsStringSync(), | |
| 355 mapUrl: p.toUri(mapPath), | |
| 356 packageRoot: p.toUri(_config.packageRoot), | |
| 357 sdkRoot: p.toUri(sdkDir)); | |
| 358 }); | |
| 359 } | |
| 360 | |
| 361 /// Returns the [BrowserManager] for [platform], which should be a browser. | |
| 362 /// | |
| 363 /// If no browser manager is running yet, starts one. | |
| 364 Future<BrowserManager> _browserManagerFor(TestPlatform platform) { | |
| 365 var manager = _browserManagers[platform]; | |
| 366 if (manager != null) return Result.release(manager); | |
| 367 | |
| 368 var completer = new Completer.sync(); | |
| 369 var path = _webSocketHandler.create(webSocketHandler(completer.complete)); | |
| 370 var webSocketUrl = url.replace(scheme: 'ws').resolve(path); | |
| 371 var hostUrl = (_config.pubServeUrl == null ? url : _config.pubServeUrl) | |
| 372 .resolve('packages/test/src/runner/browser/static/index.html') | |
| 373 .replace(queryParameters: {'managerUrl': webSocketUrl.toString()}); | |
| 374 | |
| 375 var future = BrowserManager.start(platform, hostUrl, completer.future, | |
| 376 debug: _config.pauseAfterLoad); | |
| 377 | |
| 378 // Capture errors and release them later to avoid Zone issues. This call to | |
| 379 // [_browserManagerFor] is running in a different [LoadSuite] than future | |
| 380 // calls, which means they're also running in different error zones so | |
| 381 // errors can't be freely passed between them. Storing the error or value as | |
| 382 // an explicit [Result] fixes that. | |
| 383 _browserManagers[platform] = Result.capture(future); | |
| 384 | |
| 385 return future; | |
| 386 } | |
| 387 | |
| 388 /// Close all the browsers that the server currently has open. | |
| 389 /// | |
| 390 /// Note that this doesn't close the server itself. Browser tests can still be | |
| 391 /// loaded, they'll just spawn new browsers. | |
| 392 Future closeBrowsers() { | |
| 393 var managers = _browserManagers.values.toList(); | |
| 394 _browserManagers.clear(); | |
| 395 return Future.wait(managers.map((manager) async { | |
| 396 var result = await manager; | |
| 397 if (result.isError) return; | |
| 398 await result.asValue.value.close(); | |
| 399 })); | |
| 400 } | |
| 401 | |
| 402 /// Closes the server and releases all its resources. | |
| 403 /// | |
| 404 /// Returns a [Future] that completes once the server is closed and its | |
| 405 /// resources have been fully released. | |
| 406 Future close() { | |
| 407 return _closeMemo.runOnce(() async { | |
| 408 var futures = _browserManagers.values.map((future) async { | |
| 409 var result = await future; | |
| 410 if (result.isError) return; | |
| 411 | |
| 412 await result.asValue.value.close(); | |
| 413 }).toList(); | |
| 414 | |
| 415 futures.add(_server.close()); | |
| 416 futures.add(_compilers.close()); | |
| 417 | |
| 418 await Future.wait(futures); | |
| 419 | |
| 420 if (_config.pubServeUrl == null) { | |
| 421 new Directory(_compiledDir).deleteSync(recursive: true); | |
| 422 } else { | |
| 423 _http.close(); | |
| 424 } | |
| 425 }); | |
| 426 } | |
| 427 } | |
| OLD | NEW |