1// Copyright (c) 2012 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
6function $(id) {
7  return document.getElementById(id);
8}
9
10
11function createNaClEmbed(args) {
12  var fallback = function(value, default_value) {
13    return value !== undefined ? value : default_value;
14  };
15  var embed = document.createElement('embed');
16  embed.id = args.id;
17  embed.src = args.src;
18  embed.type = fallback(args.type, 'application/x-nacl');
19  // JavaScript inconsistency: this is equivalent to class=... in HTML.
20  embed.className = fallback(args.className, 'naclModule');
21  embed.width = fallback(args.width, 0);
22  embed.height = fallback(args.height, 0);
23  return embed;
24}
25
26
27function decodeURIArgs(encoded) {
28  var args = {};
29  if (encoded.length > 0) {
30    var pairs = encoded.replace(/\+/g, ' ').split('&');
31    for (var p = 0; p < pairs.length; p++) {
32      var pair = pairs[p].split('=');
33      if (pair.length != 2) {
34        throw "Malformed argument key/value pair: '" + pairs[p] + "'";
35      }
36      args[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
37    }
38  }
39  return args;
40}
41
42
43function addDefaultsToArgs(defaults, args) {
44  for (var key in defaults) {
45    if (!(key in args)) {
46      args[key] = defaults[key];
47    }
48  }
49}
50
51
52// Return a dictionary of arguments for the test.  These arguments are passed
53// in the query string of the main page's URL.  Any time this function is used,
54// default values should be provided for every argument.  In some cases a test
55// may be run without an expected query string (manual testing, for example.)
56// Careful: all the keys and values in the dictionary are strings.  You will
57// need to manually parse any non-string values you wish to use.
58function getTestArguments(defaults) {
59  var encoded = window.location.search.substring(1);
60  var args = decodeURIArgs(encoded);
61  if (defaults !== undefined) {
62    addDefaultsToArgs(defaults, args);
63  }
64  return args;
65}
66
67
68function exceptionToLogText(e) {
69  if (typeof e == 'object' && 'message' in e && 'stack' in e) {
70    return e.message + '\n' + e.stack.toString();
71  } else if (typeof(e) == 'string') {
72    return e;
73  } else {
74    return toString(e)
75  }
76}
77
78
79// Logs test results to the server using URL-encoded RPC.
80// Also logs the same test results locally into the DOM.
81function RPCWrapper() {
82  // Work around how JS binds 'this'
83  var this_ = this;
84  // It is assumed RPC will work unless proven otherwise.
85  this.rpc_available = true;
86  // Set to true if any test fails.
87  this.ever_failed = false;
88  // Async calls can make it faster, but it can also change order of events.
89  this.async = false;
90
91  // Called if URL-encoded RPC gets a 404, can't find the server, etc.
92  function handleRPCFailure(name, message) {
93    // This isn't treated as a testing error - the test can be run without a
94    // web server that understands RPC.
95    this_.logLocal('RPC failure for ' + name + ': ' + message + ' - If you ' +
96                   'are running this test manually, this is not a problem.',
97                   'gray');
98    this_.disableRPC();
99  }
100
101  function handleRPCResponse(name, req) {
102    if (req.status == 200) {
103      if (req.responseText == 'Die, please') {
104        // TODO(eugenis): this does not end the browser process on Mac.
105        window.close();
106      } else if (req.responseText != 'OK') {
107        this_.logLocal('Unexpected RPC response to ' + name + ': \'' +
108                       req.responseText + '\' - If you are running this test ' +
109                       'manually, this is not a problem.', 'gray');
110        this_.disableRPC();
111      }
112    } else {
113      handleRPCFailure(name, req.status.toString());
114    }
115  }
116
117  // Performs a URL-encoded RPC call, given a function name and a dictionary
118  // (actually just an object - it's a JS idiom) of parameters.
119  function rpcCall(name, params) {
120    if (window.domAutomationController !== undefined) {
121      // Running as a Chrome browser_test.
122      var msg = {type: name};
123      for (var pname in params) {
124        msg[pname] = params[pname];
125      }
126      domAutomationController.setAutomationId(0);
127      domAutomationController.send(JSON.stringify(msg));
128    } else if (this_.rpc_available) {
129      // Construct the URL for the RPC request.
130      var args = [];
131      for (var pname in params) {
132        pvalue = params[pname];
133        args.push(encodeURIComponent(pname) + '=' + encodeURIComponent(pvalue));
134      }
135      var url = '/TESTER/' + name + '?' + args.join('&');
136      var req = new XMLHttpRequest();
137      // Async result handler
138      if (this_.async) {
139        req.onreadystatechange = function() {
140          if (req.readyState == XMLHttpRequest.DONE) {
141            handleRPCResponse(name, req);
142          }
143        }
144      }
145      try {
146        req.open('GET', url, this_.async);
147        req.send();
148        if (!this_.async) {
149          handleRPCResponse(name, req);
150        }
151      } catch (err) {
152        handleRPCFailure(name, err.toString());
153      }
154    }
155  }
156
157  // Pretty prints an error into the DOM.
158  this.logLocalError = function(message) {
159    this.logLocal(message, 'red');
160    this.visualError();
161  }
162
163  // If RPC isn't working, disable it to stop error message spam.
164  this.disableRPC = function() {
165    if (this.rpc_available) {
166      this.rpc_available = false;
167      this.logLocal('Disabling RPC', 'gray');
168    }
169  }
170
171  this.startup = function() {
172    // TODO(ncbray) move into test runner
173    this.num_passed = 0;
174    this.num_failed = 0;
175    this.num_errors = 0;
176    this._log('[STARTUP]');
177  }
178
179  this.shutdown = function() {
180    if (this.num_passed == 0 && this.num_failed == 0 && this.num_errors == 0) {
181      this.client_error('No tests were run. This may be a bug.');
182    }
183    var full_message = '[SHUTDOWN] ';
184    full_message += this.num_passed + ' passed';
185    full_message += ', ' + this.num_failed + ' failed';
186    full_message += ', ' + this.num_errors + ' errors';
187    this.logLocal(full_message);
188    rpcCall('Shutdown', {message: full_message, passed: !this.ever_failed});
189
190    if (this.ever_failed) {
191      this.localOutput.style.border = '2px solid #FF0000';
192    } else {
193      this.localOutput.style.border = '2px solid #00FF00';
194    }
195  }
196
197  this.ping = function() {
198    rpcCall('Ping', {});
199  }
200
201  this.heartbeat = function() {
202    rpcCall('JavaScriptIsAlive', {});
203  }
204
205  this.client_error = function(message) {
206    this.num_errors += 1;
207    this.visualError();
208    var full_message = '\n[CLIENT_ERROR] ' + exceptionToLogText(message)
209    // The client error could have been generated by logging - be careful.
210    try {
211      this._log(full_message, 'red');
212    } catch (err) {
213      // There's not much that can be done, at this point.
214    }
215  }
216
217  this.begin = function(test_name) {
218    var full_message = '[' + test_name + ' BEGIN]'
219    this._log(full_message, 'blue');
220  }
221
222  this._log = function(message, color, from_completed_test) {
223    if (typeof(message) != 'string') {
224      message = toString(message);
225    }
226
227    // For event-driven tests, output may come after the test has finished.
228    // Display this in a special way to assist debugging.
229    if (from_completed_test) {
230      color = 'orange';
231      message = 'completed test: ' + message;
232    }
233
234    this.logLocal(message, color);
235    rpcCall('TestLog', {message: message});
236  }
237
238  this.log = function(test_name, message, from_completed_test) {
239    if (message == undefined) {
240      // This is a log message that is not assosiated with a test.
241      // What we though was the test name is actually the message.
242      this._log(test_name);
243    } else {
244      if (typeof(message) != 'string') {
245        message = toString(message);
246      }
247      var full_message = '[' + test_name + ' LOG] ' + message;
248      this._log(full_message, 'black', from_completed_test);
249    }
250  }
251
252  this.fail = function(test_name, message, from_completed_test) {
253    this.num_failed += 1;
254    this.visualError();
255    var full_message = '[' + test_name + ' FAIL] ' + message
256    this._log(full_message, 'red', from_completed_test);
257  }
258
259  this.exception = function(test_name, err, from_completed_test) {
260    this.num_errors += 1;
261    this.visualError();
262    var message = exceptionToLogText(err);
263    var full_message = '[' + test_name + ' EXCEPTION] ' + message;
264    this._log(full_message, 'purple', from_completed_test);
265  }
266
267  this.pass = function(test_name, from_completed_test) {
268    this.num_passed += 1;
269    var full_message = '[' + test_name + ' PASS]';
270    this._log(full_message, 'green', from_completed_test);
271  }
272
273  this.blankLine = function() {
274    this._log('');
275  }
276
277  // Allows users to log time data that will be parsed and re-logged
278  // for chrome perf-bot graphs / performance regression testing.
279  // See: native_client/tools/process_perf_output.py
280  this.logTimeData = function(event, timeMS) {
281    this.log('NaClPerf [' + event + '] ' + timeMS + ' millisecs');
282  }
283
284  this.visualError = function() {
285    // Changing the color is defered until testing is done
286    this.ever_failed = true;
287  }
288
289  this.logLineLocal = function(text, color) {
290    text = text.replace(/\s+$/, '');
291    if (text == '') {
292      this.localOutput.appendChild(document.createElement('br'));
293    } else {
294      var mNode = document.createTextNode(text);
295      var div = document.createElement('div');
296      // Preserve whitespace formatting.
297      div.style['white-space'] = 'pre';
298      if (color != undefined) {
299        div.style.color = color;
300      }
301      div.appendChild(mNode);
302      this.localOutput.appendChild(div);
303    }
304  }
305
306  this.logLocal = function(message, color) {
307    var lines = message.split('\n');
308    for (var i = 0; i < lines.length; i++) {
309      this.logLineLocal(lines[i], color);
310    }
311  }
312
313  // Create a place in the page to output test results
314  this.localOutput = document.createElement('div');
315  this.localOutput.id = 'testresults';
316  this.localOutput.style.border = '2px solid #0000FF';
317  this.localOutput.style.padding = '10px';
318  document.body.appendChild(this.localOutput);
319}
320
321
322//
323// BEGIN functions for testing
324//
325
326
327function fail(message, info, test_status) {
328  var parts = [];
329  if (message != undefined) {
330    parts.push(message);
331  }
332  if (info != undefined) {
333    parts.push('(' + info + ')');
334  }
335  var full_message = parts.join(' ');
336
337  if (test_status !== undefined) {
338    // New-style test
339    test_status.fail(full_message);
340  } else {
341    // Old-style test
342    throw {type: 'test_fail', message: full_message};
343  }
344}
345
346
347function assert(condition, message, test_status) {
348  if (!condition) {
349    fail(message, toString(condition), test_status);
350  }
351}
352
353
354// This is accepted best practice for checking if an object is an array.
355function isArray(obj) {
356  return Object.prototype.toString.call(obj) === '[object Array]';
357}
358
359
360function toString(obj) {
361  if (typeof(obj) == 'string') {
362    return '\'' + obj + '\'';
363  }
364  try {
365    return obj.toString();
366  } catch (err) {
367    try {
368      // Arrays should do this automatically, but there is a known bug where
369      // NaCl gets array types wrong.  .toString will fail on these objects.
370      return obj.join(',');
371    } catch (err) {
372      if (obj == undefined) {
373        return 'undefined';
374      } else {
375        // There is no way to create a textual representation of this object.
376        return '[UNPRINTABLE]';
377      }
378    }
379  }
380}
381
382
383// Old-style, but new-style tests use it indirectly.
384// (The use of the "test" parameter indicates a new-style test.  This is a
385// temporary hack to avoid code duplication.)
386function assertEqual(a, b, message, test_status) {
387  if (isArray(a) && isArray(b)) {
388    assertArraysEqual(a, b, message, test_status);
389  } else if (a !== b) {
390    fail(message, toString(a) + ' != ' + toString(b), test_status);
391  }
392}
393
394
395// Old-style, but new-style tests use it indirectly.
396// (The use of the "test" parameter indicates a new-style test.  This is a
397// temporary hack to avoid code duplication.)
398function assertArraysEqual(a, b, message, test_status) {
399  var dofail = function() {
400    fail(message, toString(a) + ' != ' + toString(b), test_status);
401  }
402  if (a.length != b.length) {
403    dofail();
404  }
405  for (var i = 0; i < a.length; i++) {
406    if (a[i] !== b[i]) {
407      dofail();
408    }
409  }
410}
411
412
413// Ideally there'd be some way to identify what exception was thrown, but JS
414// exceptions are fairly ad-hoc.
415// TODO(ncbray) allow manual validation of exception types?
416function assertRaises(func, message, test_status) {
417  try {
418    func();
419  } catch (err) {
420    return;
421  }
422  fail(message, 'did not raise', test_status);
423}
424
425
426//
427// END functions for testing
428//
429
430
431function haltAsyncTest() {
432  throw {type: 'test_halt'};
433}
434
435
436function begins_with(s, prefix) {
437  if (s.length >= prefix.length) {
438    return s.substr(0, prefix.length) == prefix;
439  } else {
440    return false;
441  }
442}
443
444
445function ends_with(s, suffix) {
446  if (s.length >= suffix.length) {
447    return s.substr(s.length - suffix.length, suffix.length) == suffix;
448  } else {
449    return false;
450  }
451}
452
453
454function embed_name(embed) {
455  if (embed.name != undefined) {
456    if (embed.id != undefined) {
457      return embed.name + ' / ' + embed.id;
458    } else {
459      return embed.name;
460    }
461  } else if (embed.id != undefined) {
462    return embed.id;
463  } else {
464    return '[no name]';
465  }
466}
467
468
469// Write data to the filesystem. This will only work if the browser_tester was
470// initialized with --output_dir.
471function outputFile(name, data, onload, onerror) {
472  var xhr = new XMLHttpRequest();
473  xhr.onload = onload;
474  xhr.onerror = onerror;
475  xhr.open('POST', name, true);
476  xhr.send(data);
477}
478
479
480// Webkit Bug Workaround
481// THIS SHOULD BE REMOVED WHEN Webkit IS FIXED
482// http://code.google.com/p/nativeclient/issues/detail?id=2428
483// http://code.google.com/p/chromium/issues/detail?id=103588
484
485function ForcePluginLoadOnTimeout(elem, tester, timeout) {
486  tester.log('Registering ForcePluginLoadOnTimeout ' +
487             '(Bugs: NaCl 2428, Chrome 103588)');
488
489  var started_loading = elem.readyState !== undefined;
490
491  // Remember that the plugin started loading - it may be unloaded by the time
492  // the callback fires.
493  elem.addEventListener('load', function() {
494    started_loading = true;
495  }, true);
496
497  // Check that the plugin has at least started to load after "timeout" seconds,
498  // otherwise reload the page.
499  setTimeout(function() {
500    if (!started_loading) {
501      ForceNaClPluginReload(elem, tester);
502    }
503  }, timeout);
504}
505
506function ForceNaClPluginReload(elem, tester) {
507  if (elem.readyState === undefined) {
508    tester.log('WARNING: WebKit plugin-not-loading error detected; reloading.');
509    window.location.reload();
510  }
511}
512
513function NaClWaiter(body_element) {
514  // Work around how JS binds 'this'
515  var this_ = this;
516  var embedsToWaitFor = [];
517  // embedsLoaded contains list of embeds that have dispatched the
518  // 'loadend' progress event.
519  this.embedsLoaded = [];
520
521  this.is_loaded = function(embed) {
522    for (var i = 0; i < this_.embedsLoaded.length; ++i) {
523      if (this_.embedsLoaded[i] === embed) {
524        return true;
525      }
526    }
527    return (embed.readyState == 4) && !this_.has_errored(embed);
528  }
529
530  this.has_errored = function(embed) {
531    var msg = embed.lastError;
532    return embed.lastError != undefined && embed.lastError != '';
533  }
534
535  // If an argument was passed, it is the body element for registering
536  // event listeners for the 'loadend' event type.
537  if (body_element != undefined) {
538    var eventListener = function(e) {
539      if (e.type == 'loadend') {
540        this_.embedsLoaded.push(e.target);
541      }
542    }
543
544    body_element.addEventListener('loadend', eventListener, true);
545  }
546
547  // Takes an arbitrary number of arguments.
548  this.waitFor = function() {
549    for (var i = 0; i< arguments.length; i++) {
550      embedsToWaitFor.push(arguments[i]);
551    }
552  }
553
554  this.run = function(doneCallback, pingCallback) {
555    this.doneCallback = doneCallback;
556    this.pingCallback = pingCallback;
557
558    // Wait for up to forty seconds for the nexes to load.
559    // TODO(ncbray) use error handling mechanisms (when they are implemented)
560    // rather than a timeout.
561    this.totalWait = 0;
562    this.maxTotalWait = 40000;
563    this.retryWait = 10;
564    this.waitForPlugins();
565  }
566
567  this.waitForPlugins = function() {
568    var errored = [];
569    var loaded = [];
570    var waiting = [];
571
572    for (var i = 0; i < embedsToWaitFor.length; i++) {
573      try {
574        var e = embedsToWaitFor[i];
575        if (this.has_errored(e)) {
576          errored.push(e);
577        } else if (this.is_loaded(e)) {
578          loaded.push(e);
579        } else {
580          waiting.push(e);
581        }
582      } catch(err) {
583        // If the module is badly horked, touching lastError, etc, may except.
584        errored.push(err);
585      }
586    }
587
588    this.totalWait += this.retryWait;
589
590    if (waiting.length == 0) {
591      this.doneCallback(loaded, errored);
592    } else if (this.totalWait >= this.maxTotalWait) {
593      // Timeouts are considered errors.
594      this.doneCallback(loaded, errored.concat(waiting));
595    } else {
596      setTimeout(function() { this_.waitForPlugins(); }, this.retryWait);
597      // Capped exponential backoff
598      this.retryWait += this.retryWait/2;
599      // Paranoid: does setTimeout like floating point numbers?
600      this.retryWait = Math.round(this.retryWait);
601      if (this.retryWait > 100)
602        this.retryWait = 100;
603      // Prevent the server from thinking the test has died.
604      if (this.pingCallback)
605        this.pingCallback();
606    }
607  }
608}
609
610
611function logLoadStatus(rpc, load_errors_are_test_errors,
612                       exit_cleanly_is_an_error, loaded, waiting) {
613  for (var i = 0; i < loaded.length; i++) {
614    rpc.log(embed_name(loaded[i]) + ' loaded');
615  }
616  // Be careful when interacting with horked nexes.
617  var getCarefully = function (callback) {
618    try {
619      return callback();
620    } catch (err) {
621      return '<exception>';
622    }
623  }
624
625  var errored = false;
626  for (var j = 0; j < waiting.length; j++) {
627    // Workaround for WebKit layout bug that caused the NaCl plugin to not
628    // load.  If we see that the plugin is not loaded after a timeout, we
629    // forcibly reload the page, thereby triggering layout.  Re-running
630    // layout should make WebKit instantiate the plugin.  NB: this could
631    // make the JavaScript-based code go into an infinite loop if the
632    // WebKit bug becomes deterministic or the NaCl plugin fails after
633    // loading, but the browser_tester.py code will timeout the test.
634    //
635    // http://code.google.com/p/nativeclient/issues/detail?id=2428
636    //
637    if (waiting[j].readyState == undefined) {
638      // alert('Woot');  // -- for manual debugging
639      rpc.log('WARNING: WebKit plugin-not-loading error detected; reloading.');
640      window.location.reload();
641      throw "reload NOW";
642    }
643    var name = getCarefully(function(){
644        return embed_name(waiting[j]);
645      });
646    var ready = getCarefully(function(){
647        var readyStateString =
648        ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE'];
649        // An undefined index value will return and undefined result.
650        return readyStateString[waiting[j].readyState];
651      });
652    var last = getCarefully(function(){
653        return toString(waiting[j].lastError);
654      });
655    if (!exit_cleanly_is_an_error) {
656      // For some tests (e.g. the NaCl SDK examples) it is OK if the test
657      // exits cleanly when we are waiting for it to load.
658      //
659      // In this case, "exiting cleanly" means returning 0 from main, or
660      // calling exit(0). When this happens, the module "crashes" by posting
661      // the "crash" message, but it also assigns an exitStatus.
662      //
663      // A real crash produces an exitStatus of -1, and if the module is still
664      // running its exitStatus will be undefined.
665      var exitStatus = getCarefully(function() {
666        if (ready === 'DONE') {
667          return waiting[j].exitStatus;
668        } else {
669          return -1;
670        }
671      });
672
673      if (exitStatus === 0) {
674        continue;
675      }
676    }
677    var msg = (name + ' did not load. Status: ' + ready + ' / ' + last);
678    if (load_errors_are_test_errors) {
679      rpc.client_error(msg);
680      errored = true;
681    } else {
682      rpc.log(msg);
683    }
684  }
685  return errored;
686}
687
688
689// Contains the state for a single test.
690function TestStatus(tester, name, async) {
691  // Work around how JS binds 'this'
692  var this_ = this;
693  this.tester = tester;
694  this.name = name;
695  this.async = async;
696  this.running = true;
697
698  this.log = function(message) {
699    this.tester.rpc.log(this.name, toString(message), !this.running);
700  }
701
702  this.pass = function() {
703    // TODO raise if not running.
704    this.tester.rpc.pass(this.name, !this.running);
705    this._done();
706    haltAsyncTest();
707  }
708
709  this.fail = function(message) {
710    this.tester.rpc.fail(this.name, message, !this.running);
711    this._done();
712    haltAsyncTest();
713  }
714
715  this._done = function() {
716    if (this.running) {
717      this.running = false;
718      this.tester.testDone(this);
719    }
720  }
721
722  this.assert = function(condition, message) {
723    assert(condition, message, this);
724  }
725
726  this.assertEqual = function(a, b, message) {
727    assertEqual(a, b, message, this);
728  }
729
730  this.callbackWrapper = function(callback, args) {
731    // A stale callback?
732    if (!this.running)
733      return;
734
735    if (args === undefined)
736      args = [];
737
738    try {
739      callback.apply(undefined, args);
740    } catch (err) {
741      if (typeof err == 'object' && 'type' in err) {
742        if (err.type == 'test_halt') {
743          // New-style test
744          // If we get this exception, we can assume any callbacks or next
745          // tests have already been scheduled.
746          return;
747        } else if (err.type == 'test_fail') {
748          // Old-style test
749          // A special exception that terminates the test with a failure
750          this.tester.rpc.fail(this.name, err.message, !this.running);
751          this._done();
752          return;
753        }
754      }
755      // This is not a special type of exception, it is an error.
756      this.tester.rpc.exception(this.name, err, !this.running);
757      this._done();
758      return;
759    }
760
761    // A normal exit.  Should we move on to the next test?
762    // Async tests do not move on without an explicit pass.
763    if (!this.async) {
764      this.tester.rpc.pass(this.name);
765      this._done();
766    }
767  }
768
769  // Async callbacks should be wrapped so the tester can catch unexpected
770  // exceptions.
771  this.wrap = function(callback) {
772    return function() {
773      this_.callbackWrapper(callback, arguments);
774    };
775  }
776
777  this.setTimeout = function(callback, time) {
778    setTimeout(this.wrap(callback), time);
779  }
780
781  this.waitForCallback = function(callbackName, expectedCalls) {
782    this.log('Waiting for ' + expectedCalls + ' invocations of callback: '
783               + callbackName);
784    var gotCallbacks = 0;
785
786    // Deliberately global - this is what the nexe expects.
787    // TODO(ncbray): consider returning this function, so the test has more
788    // flexibility. For example, in the test one could count to N
789    // using a different callback before calling _this_ callback, and
790    // continuing the test. Also, consider calling user-supplied callback
791    // when done waiting.
792    window[callbackName] = this.wrap(function() {
793      ++gotCallbacks;
794      this_.log('Received callback ' + gotCallbacks);
795      if (gotCallbacks == expectedCalls) {
796        this_.log("Done waiting");
797        this_.pass();
798      } else {
799        // HACK
800        haltAsyncTest();
801      }
802    });
803
804    // HACK if this function is used in a non-async test, make sure we don't
805    // spuriously pass.  Throwing this exception forces us to behave like an
806    // async test.
807    haltAsyncTest();
808  }
809
810  // This function takes an array of messages and asserts that the nexe
811  // calls PostMessage with each of these messages, in order.
812  // Arguments:
813  //   plugin - The DOM object for the NaCl plugin
814  //   messages - An array of expected responses
815  //   callback - An optional callback function that takes the current message
816  //              string as an argument
817  this.expectMessageSequence = function(plugin, messages, callback) {
818    this.assert(messages.length > 0, 'Must provide at least one message');
819    var local_messages = messages.slice();
820    var listener = function(message) {
821      if (message.data.indexOf('@:') == 0) {
822        // skip debug messages
823        this_.log('DEBUG: ' + message.data.substr(2));
824      } else {
825        this_.assertEqual(message.data, local_messages.shift());
826        if (callback !== undefined) {
827          callback(message.data);
828        }
829      }
830      if (local_messages.length == 0) {
831        this_.pass();
832      } else {
833        this_.expectEvent(plugin, 'message', listener);
834      }
835    }
836    this.expectEvent(plugin, 'message', listener);
837  }
838
839  this.expectEvent = function(src, event_type, listener) {
840    var wrapper = this.wrap(function(e) {
841      src.removeEventListener(event_type, wrapper, false);
842      listener(e);
843    });
844    src.addEventListener(event_type, wrapper, false);
845  }
846}
847
848
849function Tester(body_element) {
850  // Work around how JS binds 'this'
851  var this_ = this;
852  // The tests being run.
853  var tests = [];
854  this.rpc = new RPCWrapper();
855  this.waiter = new NaClWaiter(body_element);
856
857  var load_errors_are_test_errors = true;
858  var exit_cleanly_is_an_error = true;
859
860  var parallel = false;
861
862  //
863  // BEGIN public interface
864  //
865
866  this.loadErrorsAreOK = function() {
867    load_errors_are_test_errors = false;
868  }
869
870  this.exitCleanlyIsOK = function() {
871    exit_cleanly_is_an_error = false;
872  };
873
874  this.log = function(message) {
875    this.rpc.log(message);
876  }
877
878  // If this kind of test exits cleanly, it passes
879  this.addTest = function(name, testFunction) {
880    tests.push({name: name, callback: testFunction, async: false});
881  }
882
883  // This kind of test does not pass until "pass" is explicitly called.
884  this.addAsyncTest = function(name, testFunction) {
885    tests.push({name: name, callback: testFunction, async: true});
886  }
887
888  this.run = function() {
889    this.rpc.startup();
890    this.startHeartbeat();
891    this.waiter.run(
892      function(loaded, waiting) {
893        var errored = logLoadStatus(this_.rpc, load_errors_are_test_errors,
894                                    exit_cleanly_is_an_error,
895                                    loaded, waiting);
896        if (errored) {
897          this_.rpc.blankLine();
898          this_.rpc.log('A nexe load error occured, aborting testing.');
899          this_._done();
900        } else {
901          this_.startTesting();
902        }
903      },
904      function() {
905        this_.rpc.ping();
906      }
907    );
908  }
909
910  this.runParallel = function() {
911    parallel = true;
912    this.run();
913  }
914
915  // Takes an arbitrary number of arguments.
916  this.waitFor = function() {
917    for (var i = 0; i< arguments.length; i++) {
918      this.waiter.waitFor(arguments[i]);
919    }
920  }
921
922  //
923  // END public interface
924  //
925
926  this.startHeartbeat = function() {
927    var rpc = this.rpc;
928    var heartbeat = function() {
929      rpc.heartbeat();
930      setTimeout(heartbeat, 500);
931    }
932    heartbeat();
933  }
934
935  this.launchTest = function(testIndex) {
936    var testDecl = tests[testIndex];
937    var currentTest = new TestStatus(this, testDecl.name, testDecl.async);
938    setTimeout(currentTest.wrap(function() {
939      this_.rpc.blankLine();
940      this_.rpc.begin(currentTest.name);
941      testDecl.callback(currentTest);
942    }), 0);
943  }
944
945  this._done = function() {
946    this.rpc.blankLine();
947    this.rpc.shutdown();
948  }
949
950  this.startTesting = function() {
951    if (tests.length == 0) {
952      // No tests specified.
953      this._done();
954      return;
955    }
956
957    this.testCount = 0;
958    if (parallel) {
959      // Launch all tests.
960      for (var i = 0; i < tests.length; i++) {
961        this.launchTest(i);
962      }
963    } else {
964      // Launch the first test.
965      this.launchTest(0);
966    }
967  }
968
969  this.testDone = function(test) {
970    this.testCount += 1;
971    if (this.testCount < tests.length) {
972      if (!parallel) {
973        // Move on to the next test if they're being run one at a time.
974        this.launchTest(this.testCount);
975      }
976    } else {
977      this._done();
978    }
979  }
980}
981