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