Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(869)

Side by Side Diff: tools/testing/dart/browser_controler.dart

Issue 14757019: Add browser controller and allow it to be used under a flag. (Closed) Base URL: http://dart.googlecode.com/svn/branches/bleeding_edge/dart/
Patch Set: Created 7 years, 7 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
(Empty)
1 // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
kustermann 2013/05/13 16:00:00 2013
ricow1 2013/05/14 07:20:58 Done.
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 library browser;
5
6 import "dart:io";
7 import "dart:async";
8 import "dart:core";
kustermann 2013/05/13 16:00:00 You could sort them.
ricow1 2013/05/14 07:20:58 Done.
9
10
11 /** Class describing the interface for communicating with browsers. */
12 class Browser {
13 // Browsers actually takes a while to cleanup after itself when closing
14 // Give it sufficient time to do that.
15 static final Duration killRepeatInternal = const Duration(seconds: 10);
16 static final int killRetries = 5;
17 StringBuffer _stdout = new StringBuffer();
18 StringBuffer _stderr = new StringBuffer();
19 StringBuffer _usageLog = new StringBuffer();
20 // This function is called when the process is closed.
21 // This is extracted to an external function so that we can do additional
22 // functionality when the process closes (cleanup and call onExit)
23 Function _processClosed;
24 // This is called after the process is closed, after _processClosed has
25 // been called, but before onExit. Subclasses can use this to cleanup
26 // any browser specific resources (temp directories, profiles, etc)
27 Function _cleanup;
28
29 /** The version of the browser - normally set when starting a browser */
30 String version = "";
31 /**
32 * The underlying process - don't mess directly with this if you don't
33 * know what you are doing (this is an interactive process that needs
34 * special threatment to not leak).
35 */
36 Process process;
37
38 /**
39 * Id of the browser
40 */
41 String id;
42
43 /** Callback that will be executed when the browser has closed */
44 Function onClose;
45
46 /** Print everything (stdout, stderr, usageLog) whenever we add to it */
47 bool debugPrint = false;
48
49 void _logEvent(String event) {
50 String toLog = "$this ($id) - ${new DateTime.now()}: $event \n";
51 if (debugPrint) print("usageLog: $toLog");
52 _usageLog.write(toLog);
53 }
54
55 void _addStdout(String output) {
56 if (debugPrint) print("stdout: $output");
57 _stdout.write(output);
58 }
59
60 void _addStderr(String output) {
61 if (debugPrint) print("stderr: $output");
62 _stderr.write(output);
63 }
64
65 // Kill the underlying process using the supplied kill function
66 // If there is a alternativeKillFunction we will use that after trying
67 // the default killFunction.
68 Future _killIt(killFunction, retries, [alternativeKillFunction = null]) {
69 Completer<bool> completer = new Completer<bool>();
70
71 // To capture non successfull attempts we set up a timer that will
72 // trigger a retry (using the alternativeKillFunction if supplied).
73 Timer timer = new Timer(killRepeatInternal, () {
74 // Remove the handler, we will set this again in the call to killIt
75 // below
76 if (retries <= 0) {
77 _logEvent("Could not kill the process, not trying anymore");
78 // TODO(ricow): Should we crash the test script here and
79 // write out all our log. This is basically not a situation
80 // that we want to ignore. We could potentially have a handler we
81 // can call if this happens, which will shutdown the main process
82 // with info that people should contact [ricow,kustermann,?]
83 completer.complete(false);
84 }
85 _logEvent("Could not kill the process, retrying");
86 var nextKillFunction = killFunction;
87 if (alternativeKillFunction != null) {
88 nextKillFunction = alternativeKillFunction;
89 }
90 _killIt(nextKillFunction, --retries).then((success) {
kustermann 2013/05/13 16:00:00 Why not 'retries -1'?
ricow1 2013/05/14 07:20:58 Done.
91 completer.complete(success);
92 });
93 });
94
95 // Make sure we intercept onExit calls and eliminate the timer.
96 _processClosed = () {
97 timer.cancel();
98 _logEvent("Proccess exited, cancel timer in kill loop");
99 _processClosed = null;
100 completer.complete(true);
101 };
102
103
104 _logEvent("calling kill function");
105 if (killFunction()) {
106 // We successfully sent the signal.
107 _logEvent("killing signal sent");
108 } else {
109 _logEvent("The process is already dead, kill signal could not be send");
110 completer.complete(true);
111 }
112 return completer.future;
113 }
114
115
116 /** Close the browser */
117 Future<bool> close() {
118 _logEvent("Close called on browser");
119 if (process == null) {
120 _logEvent("No process open, nothing to kill.");
121 return new Future.immediate(true);
122 }
123 var killFunction = process.kill;
124 // We use a SIGKILL signal if we don't kill the process in the first go.
125 var alternativeKillFunction =
126 () { return process.kill(ProcessSignal.SIGKILL);};
127 return _killIt(killFunction, killRetries, alternativeKillFunction);
128 }
129
130 /**
131 * Start the browser using the supplied argument.
132 * This sets up the error handling and usage logging.
133 */
134 Future<bool> startBrowser(String command, List<String> arguments) {
135 return Process.start(command, arguments).then((p) {
136 process = p;
137 p.stdout.transform(new StringDecoder()).listen((data) {
138 _addStdout(data);
139 }, onError: (e) {
140 // This should _never_ happen, but we really want this in the log
141 // if it actually does due to dart:io or vm bug.
142 _usageLog.add("An error occured in the process stdout handling: $e");
143 });
144
145 p.stderr.transform(new StringDecoder()).listen((data) {
146 _addStderr(data);
147 }, onError: (e) {
148 // This should _never_ happen, but we really want this in the log
149 // if it actually does due to dart:io or vm bug.
150 _usageLog.add("An error occured in the process stderr handling: $e");
151 });
kustermann 2013/05/13 16:00:00 Indentation.
ricow1 2013/05/14 07:20:58 Done.
152
153 process.exitCode.then((exitCode) {
154 _logEvent("Browser closed with exitcode $exitCode");
155 if (_processClosed != null) _processClosed();
156 if (_cleanup != null) _cleanup();
157 if (onClose != null) onClose(exitCode);
158 });
159 return true;
160 }).catchError((e) {
161 _logEvent("Running $binary $arguments failed with $e");
162 return false;
163 });
164 }
165
166 /**
167 * Get any stdout that the browser wrote during execution.
168 */
169 String get stdout => _stdout.toString();
170 String get stderr => _stderr.toString();
171 String get usageLog => _usageLog.toString();
172
173 String toString();
174 /** Starts the browser loading the given url */
175 Future<bool> start(String url);
176 }
177
178
179 class Chrome extends Browser {
180 /**
181 * The binary used to run chrome - changing this can be nececcary for
182 * testing or using non standard chrome installation.
183 */
184 const String binary = "google-chrome";
kustermann 2013/05/13 16:00:00 We probably need to adjust the path to the binary
ricow1 2013/05/14 07:20:58 Yes, we may
185
186 Future<bool> start(String url) {
187 _logEvent("Starting chrome browser on: $url");
188 // Get the version and log that.
189 return Process.run(binary, ["--version"]).then((var versionResult) {
190 if (versionResult.exitCode != 0) {
191 _logEvent("Failed to chrome get version");
192 _logEvent("Make sure $binary is a valid program for running chrome");
193 return new Future.immediate(false);
194 }
195 version = versionResult.stdout;
196 _logEvent("Got version: $version");
197
198 return new Directory('').createTemp().then((userDir) {
199 _cleanup = () { userDir.delete(recursive: true); };
200 var args = ["--user-data-dir=${userDir.path}", url,
201 "--disable-extensions", "--disable-popup-blocking",
202 "--bwsi"];
203 return startBrowser(binary, args);
204
205 });
206 }).catchError((e) {
207 _logEvent("Running $binary --version failed with $e");
208 return false;
209 });
210 }
211
212 String toString() => "Chrome";
213 }
214
215 class Firefox extends Browser {
216 /**
217 * The binary used to run firefox - changing this can be nececcary for
218 * testing or using non standard firefox installation.
219 */
220 const String binary = "firefox";
221
222 const String enablePopUp =
223 "user_pref(\"dom.disable_open_during_load\", false);";
224
225 Future _createPreferenceFile(var path) {
226 var file = new File("${path.toString()}/user.js");
227 var randomFile = file.openSync(FileMode.WRITE);
228 randomFile.writeStringSync(enablePopUp);
229 randomFile.close();
230 }
231
232
233 Future<bool> start(String url) {
234 _logEvent("Starting firefox browser on: $url");
235 // Get the version and log that.
236 return Process.run(binary, ["--version"]).then((var versionResult) {
237 if (versionResult.exitCode != 0) {
238 _logEvent("Failed to firefox get version");
239 _logEvent("Make sure $binary is a valid program for running firefox");
240 return new Future.immediate(false);
241 }
242 version = versionResult.stdout;
243 _logEvent("Got version: $version");
244
245 return new Directory('').createTemp().then((userDir) {
246 _createPreferenceFile(userDir.path);
247 _cleanup = () { userDir.delete(recursive: true); };
248 var args = ["-profile", "${userDir.path}",
249 "-no-remote", "-new-instance", url];
250 return startBrowser(binary, args);
251
252 });
253 }).catchError((e) {
254 _logEvent("Running $binary --version failed with $e");
255 return false;
256 });
257 }
258
259 String toString() => "Firefox";
260 }
261
262
263 /**
264 * Describes the current state of a browser used for testing.
265 */
266 class BrowserTestingStatus {
267 // TODO(ricow): Add prefetching to the browsers. We spend a lot of time waiting
268 // for the next test. Handling timeouts is the hard part of this!
269
270
271 Browser browser;
272 BrowserTest currentlyRunning;
273 // This is currently not used for anything except for error reporting.
274 // Given the usefulness of this in debugging issues this should not be
275 // removed even when we have really stable system.
276 BrowserTest last;
277 //
278 bool timeout = false;
279 BrowserTestingStatus(Browser this.browser);
280 }
281
282
283 /**
284 * Describes a single test to be run int the browser.
285 */
286 class BrowserTest {
287 // TODO(ricow): Add timeout callback instead of the string passing hack.
288 Function doneCallback;
289 String url;
290 int timeout;
291 // We store this here for easy access when tests time out (instead of
292 // capturing this in a closure)
293 Timer timeoutTimer;
294
295 // Used for debugging, this is simply a unique identifier assigned to each
296 // test.
297 int id;
298 static int _idCounter = 0;
299
300 BrowserTest(this.url, this.doneCallback, this.timeout) {
301 id = _idCounter++;
302 }
303 }
304
305
306 /**
307 * Encapsulates all the functionality for running tests in browsers.
308 * The interface is rather simple. After starting the runner tests
309 * are simply added to the queue and a the supplied callbacks are called
310 * whenever a test completes.
311 */
312 class BrowserTestRunner {
313 int maxNumBrowsers;
kustermann 2013/05/13 16:00:00 I think we should always use '_' for private membe
ricow1 2013/05/14 07:20:58 That depends on how easy you want testing to be
314 String browserName;
315
316 bool underTermination = false;
317
318 List<BrowserTest> testQueue = new List<BrowserTest>();
319 Map<String, BrowserTestingStatus> browserStatus =
320 new Map<String, BrowserTestingStatus>();
321 // This cache is used to guarantee that we never see double reporting.
322 // If we do we need to provide developers with this information.
323 // We don't add urls to the cache until we have run it.
324 Map<int, String> testCache = new Map<int, String>();
325 List<int> doubleReportingTests = new List<int>();
326
327 BrowserTestingServer testingServer;
328
329 BrowserTestRunner(String this.browserName, int this.maxNumBrowsers);
330
331 Future<bool> start() {
332 testingServer = new BrowserTestingServer();
333 return testingServer.start().then((_) {
334 testingServer.testDoneCallBack = handleResults;
335 testingServer.nextTestCallBack = getNextTest;
336 var futures = [];
337 for (int i = 0; i < maxNumBrowsers; i++) {
338 var browser = getInstance();
339 var id = "BROWSER$i";
340 // We store this in case we need to kill the browser.
341 browser.id = id;
342 var future =
343 browser.start(testingServer.getDriverUrl(id)).then((success) {
344 if (success) {
345 browserStatus[id] = new BrowserTestingStatus(browser);
346 }
347 return success;
348 });
349 futures.add(future);
350 }
351 return Future.wait(futures).then((values) {
352 return !values.contains(false);
353 });
354 });
355 }
356
357 var timedOut = [];
358
359 void handleResults(String browserId, String output, int testId) {
360 var status = browserStatus[browserId];
361 if (testCache.containsKey(testId)) {
362 doubleReportingTests.add(testId);
kustermann 2013/05/13 16:00:00 Maybe we should make the buildbot red in case of "
ricow1 2013/05/14 07:20:58 I think that would make sense yes, but until the u
363 return;
364 }
365
366 if (status.timeout) {
367 // We don't do anything, this browser is currently being killed and
368 // replaced.
369 } else if (status.currentlyRunning != null) {
370 status.currentlyRunning.timeoutTimer.cancel();
371 if (status.currentlyRunning.id != testId) {
372 print("Expected test id ${status.currentlyRunning.id} for"
373 "${status.currentlyRunning.url}");
374 print("Got test id ${testId}");
375 print("Last test id was ${status.last.id} for "
376 "${status.currentlyRunning.url}");
377 throw("This should never happen, wrong test id");
378 }
379 testCache[testId] = status.currentlyRunning.url;
380 status.currentlyRunning.doneCallback(output);
381 status.last = status.currentlyRunning;
382 status.currentlyRunning = null;
383 } else {
384 print("\nThis is bad, should never happen, handleResult no test");
385 print("URL: ${status.last.url}");
386 print(output);
387 terminate().then((_) {
388 exit(1);
389 });
390 }
391 }
392
393 void handleTimeout(BrowserTestingStatus status) {
394 // We simply kill the browser and starts up a new one!
395 // We could be smarter here, but it does not seems like it is worth it.
396 status.timeout = true;
397 timedOut.add(status.currentlyRunning.url);
398 // Start the new browser first
399 var browser = getInstance();
400 var id = status.browser.id;
401 browser.start(testingServer.getDriverUrl(id)).then((success) {
kustermann 2013/05/13 16:00:00 IMHO this is not the way we should do it. We shoul
ricow1 2013/05/14 07:20:58 I did not really think about the mobile case here,
402 // We may have started terminating in the mean time.
403 if (underTermination) {
404 browser.close().then((success) {
405 // We should never hit this, print it out.
406 if (!success) {
407 print("Could not kill browser ($id) started due to timeout");
408 }
409 });
410 return;
411 }
412 if (success) {
413 status.browser.close().then((closed) {
414 if (!closed) {
415 // BAD, we could not kill the browser.
416 print("could not kill browser $id");
417 }
418 });
419 browser.id = id;
420 status.browser = browser;
421 status.timeout = false;
422 } else {
423 // TODO(ricow): Handle this better.
424 print("This is bad, should never happen, could not start browser");
425 exit(1);
426 }
427 });
428
429 status.currentlyRunning.doneCallback("TIMEOUT");
430 status.currentlyRunning = null;
431 }
432
433 BrowserTest getNextTest(String browserId) {
434 if (testQueue.isEmpty) return null;
435 var status = browserStatus[browserId];
436 if (status == null) return null;
437 // We are currently terminating this browser, don't start a new test.
438 if (status.timeout) return null;
439 BrowserTest test = testQueue.removeLast();
440 if (status.currentlyRunning == null) {
441 status.currentlyRunning = test;
442 } else {
443 // TODO(ricow): Handle this better.
444 print("This is bad, should never happen, getNextTest all full");
445 print("Old test was: ${status.currentlyRunning.url}");
446 print("Timed out tests:");
447 for (var v in timedOut) {
448 print(" $v");
449 }
450 exit(1);
451 }
452 Timer timer = new Timer(new Duration(seconds: test.timeout),
453 () { handleTimeout(status); });
454 status.currentlyRunning.timeoutTimer = timer;
455 return test;
456 }
457
458 void queueTest(BrowserTest test) {
459 testQueue.add(test);
460 }
461
462 void printDoubleReportingTests() {
463 if (doubleReportingTests.length == 0) return;
464 // Currently we just report this here, we could have a callback to the
465 // encapsulating environment.
466 print("");
467 print("Double reporting tests");
468 for (var id in doubleReportingTests) {
469 print(" ${testCache[id]}");
470 }
471 }
472
473 Future<bool> terminate() {
474 var futures = [];
475 underTermination = true;
476 testingServer.underTermination = true;
477 for (BrowserTestingStatus status in browserStatus.values) {
478 futures.add(status.browser.close());
479 }
480 return Future.wait(futures).then((values) {
481 testingServer.httpServer.close();
482 printDoubleReportingTests();
483 return !values.contains(false);
484 });
485 }
486
487 Browser getInstance() {
488 if (browserName == "chrome") {
489 return new Chrome();
490 } else if (browserName == "firefox") {
491 return new Firefox();
492 }
493 throw "Non supported browser for browser controller";
494 }
495 }
496
497 class BrowserTestingServer {
498 const String server = "127.0.0.1";
kustermann 2013/05/13 16:00:00 Please make a comment descripting the API (see for
ricow1 2013/05/14 07:20:58 Done.
499 const String driverPath = "/driver";
500 const String nextTestPath = "/next_test";
501 const String reportPath = "/report";
502 const String waitSignal = "WAIT";
503 const String terminateSignal = "TERMINATE";
504
505 var testCount = 0;
506 var httpServer;
507 bool underTermination = false;
508
509 Function testDoneCallBack;
510 Function nextTestCallBack;
511
512 Future start() {
513 return HttpServer.bind(server, 0).then((createdServer) {
514 httpServer = createdServer;
515 void handler(HttpRequest request) {
516 if (request.uri.path.startsWith(reportPath)) {
517 var browserId = request.uri.path.substring(reportPath.length + 1);
518 var testId = int.parse(request.queryParameters["id"].split("=")[1]);
519
520 handleReport(request, browserId, testId);
521 // handleReport will asynchroniously fetch the data and will handle
522 // the closing of the streams.
523 return;
524 }
525 var textResponse = "";
526 if (request.uri.path.startsWith(driverPath)) {
527 var browserId = request.uri.path.substring(driverPath.length + 1);
528 textResponse = getDriverPage(browserId);
529 } else if (request.uri.path.startsWith(nextTestPath)) {
530 var browserId = request.uri.path.substring(nextTestPath.length + 1);
531 textResponse = getNextTest(browserId);
532 } else {
533 // We silently ignore other requests.
534 }
535 request.response.write(textResponse);
kustermann 2013/05/13 16:00:00 It is important to drain the request stream (even
ricow1 2013/05/14 07:20:58 Done.
536 request.response.close();
kustermann 2013/05/13 16:00:00 I'm not 100% sure if we need to catch the future h
ricow1 2013/05/14 07:20:58 The checked in binary does not return a future on
537 request.response.done.catchError((error) {
kustermann 2013/05/13 16:00:00 Indentation
ricow1 2013/05/14 07:20:58 Done.
ricow1 2013/05/14 07:20:58 Done.
538 if (!underTermination) {
539 print("URI ${request.uri}");
540 print("Textresponse $textResponse");
541 throw("Error returning content to browser: $error");
542 }
543 });
544 }
545 void errorHandler(e) {
546 if (!underTermination) print("Error occured in httpserver: $e");
547 };
548 httpServer.listen(handler, onError: errorHandler);
549 return true;
550 });
551 }
552
553 void handleReport(HttpRequest request, String browserId, var testId) {
554 StringBuffer buffer = new StringBuffer();
555 request.transform(new StringDecoder()).listen((data) {
kustermann 2013/05/13 16:00:00 You could use 'fold()'.
ricow1 2013/05/14 07:20:58 No can do. Checked in bin: r20101, fold introduced
556 buffer.write(data);
557 }, onDone: () {
558 String back = buffer.toString();
559 request.response.close();
560 testDoneCallBack(browserId, back, testId);
561 }, onError: (error) {print(error);});
562 }
563
564 String getNextTest(String browserId) {
565 var nextTest = nextTestCallBack(browserId);
566 if (underTermination) {
567 // Browsers will be killed shortly, send them a terminate signal so
568 // that they stop pulling.
569 return terminateSignal;
570 } else if (nextTest == null) {
571 // We don't currently have any tests ready for consumption, wait.
572 return waitSignal;
573 } else {
574 return "${nextTest.url}#id=${nextTest.id}";
575 }
576 }
577
578 String getDriverUrl(String browserId) {
579 if (httpServer == null) {
580 print("Bad browser testing server, you are not started yet. Can't "
581 "produce driver url");
582 exit(1);
583 // This should never happen - exit immediately;
584 }
585 return "http://$server:${httpServer.port}/driver/$browserId";
586 }
587
588
589 String getDriverPage(String browserId) {
590 String driverContent = """
591 <!DOCTYPE html><html>
592 <head>
593 <title>Driving page</title>
594 <script type='text/javascript'>
595 var numberOfTests = 0;
596 var currentId;
597 var testing_window;
598
599 function newTaskHandler() {
600 if(this.readyState == this.DONE) {
601 if(this.status == 200) {
kustermann 2013/05/13 16:00:00 spaces after 'if'
ricow1 2013/05/14 07:20:58 Done, plus above (no stealing my signature comment
602 if (this.responseText == '$waitSignal') {
603 setTimeout(getNextTask, 500);
604 } else if (this.responseText == 'TERMINATE') {
605 // Don't do anything, we will be killed shortly.
606 } else {
607 // TODO(ricow): Do something more clever here.
608 if (nextTask != undefined) alert('This is really bad');
609 var split = this.responseText.split('#');
kustermann 2013/05/13 16:00:00 Please document how the response text looks like.
ricow1 2013/05/14 07:20:58 Done.
610 var nextTask = split[0];
611 id = split[1];
612 run(nextTask);
613 }
614 } else {
615 // We are basically fucked - do something clever.
616 }
617 }
618 }
619
620 function getNextTask() {
621 var client = new XMLHttpRequest();
622 client.onreadystatechange = newTaskHandler;
623 client.open('GET', '$nextTestPath/$browserId');
624 client.send();
625 }
626
627 function run(url) {
628 numberOfTests++;
629 document.getElementById('number').innerHTML = numberOfTests;
630 if (testing_window == undefined) {
631 testing_window = window.open(url);
632 } else {
633 testing_window.location = url;
634 }
635 }
636
637 function reportMessage(msg) {
638 var client = new XMLHttpRequest();
639 function handleReady() {
640 if (this.readyState == this.DONE) {
641 getNextTask();
642 }
643 }
644 client.onreadystatechange = handleReady;
645 client.open('POST', '$reportPath/${browserId}?id=' + id);
646 client.setRequestHeader('Content-type',
647 'application/x-www-form-urlencoded');
648 client.send(msg);
649 // TODO(ricow) add error handling to somehow report the fact that
650 // we could not send back a result.
651 }
652
653 function messageHandler(e) {
654 var msg = e.data;
655 if (typeof msg != 'string') return;
656 reportMessage(msg);
657 }
658
659 window.addEventListener('message', messageHandler, false);
660 waitForDone = false;
661
662 getNextTask();
663
664 </script>
665 </head>
666 <body>
667 Dart test driver, number of tests: <div id="number"></div>
668 </body>
669 </html>
670 """;
671 return driverContent;
672 }
673 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698