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 |