browser/devtools/commandline/test/helpers.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:dba7b1b12f81
1 /*
2 * Copyright 2012, Mozilla Foundation and contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 'use strict';
18
19 // A copy of this code exists in firefox mochitests. They should be kept
20 // in sync. Hence the exports synonym for non AMD contexts.
21 this.EXPORTED_SYMBOLS = [ 'helpers' ];
22 var helpers = {};
23 this.helpers = helpers;
24
25 var TargetFactory = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.TargetFactory;
26 var require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
27
28 var assert = { ok: ok, is: is, log: info };
29 var util = require('gcli/util/util');
30 var promise = require('gcli/util/promise');
31 var cli = require('gcli/cli');
32 var KeyEvent = require('gcli/util/util').KeyEvent;
33 var gcli = require('gcli/index');
34
35 /**
36 * See notes in helpers.checkOptions()
37 */
38 var createFFDisplayAutomator = function(display) {
39 var automator = {
40 setInput: function(typed) {
41 return display.inputter.setInput(typed);
42 },
43
44 setCursor: function(cursor) {
45 return display.inputter.setCursor(cursor);
46 },
47
48 focus: function() {
49 return display.inputter.focus();
50 },
51
52 fakeKey: function(keyCode) {
53 var fakeEvent = {
54 keyCode: keyCode,
55 preventDefault: function() { },
56 timeStamp: new Date().getTime()
57 };
58
59 display.inputter.onKeyDown(fakeEvent);
60
61 if (keyCode === KeyEvent.DOM_VK_BACK_SPACE) {
62 var input = display.inputter.element;
63 input.value = input.value.slice(0, -1);
64 }
65
66 return display.inputter.handleKeyUp(fakeEvent);
67 },
68
69 getInputState: function() {
70 return display.inputter.getInputState();
71 },
72
73 getCompleterTemplateData: function() {
74 return display.completer._getCompleterTemplateData();
75 },
76
77 getErrorMessage: function() {
78 return display.tooltip.errorEle.textContent;
79 }
80 };
81
82 Object.defineProperty(automator, 'focusManager', {
83 get: function() { return display.focusManager; },
84 enumerable: true
85 });
86
87 Object.defineProperty(automator, 'field', {
88 get: function() { return display.tooltip.field; },
89 enumerable: true
90 });
91
92 return automator;
93 };
94
95 /**
96 * Warning: For use with Firefox Mochitests only.
97 *
98 * Open a new tab at a URL and call a callback on load, and then tidy up when
99 * the callback finishes.
100 * The function will be passed a set of test options, and will usually return a
101 * promise to indicate that the tab can be cleared up. (To be formal, we call
102 * Promise.resolve() on the return value of the callback function)
103 *
104 * The options used by addTab include:
105 * - chromeWindow: XUL window parent of created tab. a.k.a 'window' in mochitest
106 * - tab: The new XUL tab element, as returned by gBrowser.addTab()
107 * - target: The debug target as defined by the devtools framework
108 * - browser: The XUL browser element for the given tab
109 * - window: Content window for the created tab. a.k.a 'content' in mochitest
110 * - isFirefox: Always true. Allows test sharing with GCLI
111 *
112 * Normally addTab will create an options object containing the values as
113 * described above. However these options can be customized by the third
114 * 'options' parameter. This has the ability to customize the value of
115 * chromeWindow or isFirefox, and to add new properties.
116 *
117 * @param url The URL for the new tab
118 * @param callback The function to call on page load
119 * @param options An optional set of options to customize the way the tests run
120 */
121 helpers.addTab = function(url, callback, options) {
122 waitForExplicitFinish();
123
124 options = options || {};
125 options.chromeWindow = options.chromeWindow || window;
126 options.isFirefox = true;
127
128 var tabbrowser = options.chromeWindow.gBrowser;
129 options.tab = tabbrowser.addTab();
130 tabbrowser.selectedTab = options.tab;
131 options.browser = tabbrowser.getBrowserForTab(options.tab);
132 options.target = TargetFactory.forTab(options.tab);
133
134 var loaded = helpers.listenOnce(options.browser, "load", true).then(function(ev) {
135 options.document = options.browser.contentDocument;
136 options.window = options.document.defaultView;
137
138 var reply = callback.call(null, options);
139
140 return promise.resolve(reply).then(null, function(error) {
141 ok(false, error);
142 }).then(function() {
143 tabbrowser.removeTab(options.tab);
144
145 delete options.window;
146 delete options.document;
147
148 delete options.target;
149 delete options.browser;
150 delete options.tab;
151
152 delete options.chromeWindow;
153 delete options.isFirefox;
154 });
155 });
156
157 options.browser.contentWindow.location = url;
158 return loaded;
159 };
160
161 /**
162 * Open a new tab
163 * @param url Address of the page to open
164 * @param options Object to which we add properties describing the new tab. The
165 * following properties are added:
166 * - chromeWindow
167 * - tab
168 * - browser
169 * - target
170 * - document
171 * - window
172 * @return A promise which resolves to the options object when the 'load' event
173 * happens on the new tab
174 */
175 helpers.openTab = function(url, options) {
176 waitForExplicitFinish();
177
178 options = options || {};
179 options.chromeWindow = options.chromeWindow || window;
180 options.isFirefox = true;
181
182 var tabbrowser = options.chromeWindow.gBrowser;
183 options.tab = tabbrowser.addTab();
184 tabbrowser.selectedTab = options.tab;
185 options.browser = tabbrowser.getBrowserForTab(options.tab);
186 options.target = TargetFactory.forTab(options.tab);
187
188 options.browser.contentWindow.location = url;
189
190 return helpers.listenOnce(options.browser, "load", true).then(function() {
191 options.document = options.browser.contentDocument;
192 options.window = options.document.defaultView;
193 return options;
194 });
195 };
196
197 /**
198 * Undo the effects of |helpers.openTab|
199 * @param options The options object passed to |helpers.openTab|
200 * @return A promise resolved (with undefined) when the tab is closed
201 */
202 helpers.closeTab = function(options) {
203 options.chromeWindow.gBrowser.removeTab(options.tab);
204
205 delete options.window;
206 delete options.document;
207
208 delete options.target;
209 delete options.browser;
210 delete options.tab;
211
212 delete options.chromeWindow;
213 delete options.isFirefox;
214
215 return promise.resolve(undefined);
216 };
217
218 /**
219 * Open the developer toolbar in a tab
220 * @param options Object to which we add properties describing the developer
221 * toolbar. The following properties are added:
222 * - automator
223 * - requisition
224 * @return A promise which resolves to the options object when the 'load' event
225 * happens on the new tab
226 */
227 helpers.openToolbar = function(options) {
228 return options.chromeWindow.DeveloperToolbar.show(true).then(function() {
229 var display = options.chromeWindow.DeveloperToolbar.display;
230 options.automator = createFFDisplayAutomator(display);
231 options.requisition = display.requisition;
232 });
233 };
234
235 /**
236 * Undo the effects of |helpers.openToolbar|
237 * @param options The options object passed to |helpers.openToolbar|
238 * @return A promise resolved (with undefined) when the toolbar is closed
239 */
240 helpers.closeToolbar = function(options) {
241 return options.chromeWindow.DeveloperToolbar.hide().then(function() {
242 delete options.automator;
243 delete options.requisition;
244 });
245 };
246
247 /**
248 * A helper to work with Task.spawn so you can do:
249 * return Task.spawn(realTestFunc).then(finish, helpers.handleError);
250 */
251 helpers.handleError = function(ex) {
252 console.error(ex);
253 ok(false, ex);
254 finish();
255 };
256
257 /**
258 * A helper for calling addEventListener and then removeEventListener as soon
259 * as the event is called, passing the results on as a promise
260 * @param element The DOM element to listen on
261 * @param event The name of the event to listen for
262 * @param useCapture Should we use the capturing phase?
263 * @return A promise resolved with the event object when the event first happens
264 */
265 helpers.listenOnce = function(element, event, useCapture) {
266 var deferred = promise.defer();
267 var onEvent = function(ev) {
268 element.removeEventListener(event, onEvent, useCapture);
269 deferred.resolve(ev);
270 };
271 element.addEventListener(event, onEvent, useCapture);
272 return deferred.promise;
273 };
274
275 /**
276 * A wrapper for calling Services.obs.[add|remove]Observer using promises.
277 * @param topic The topic parameter to Services.obs.addObserver
278 * @param ownsWeak The ownsWeak parameter to Services.obs.addObserver with a
279 * default value of false
280 * @return a promise that resolves when the ObserverService first notifies us
281 * of the topic. The value of the promise is the first parameter to the observer
282 * function other parameters are dropped.
283 */
284 helpers.observeOnce = function(topic, ownsWeak=false) {
285 let deferred = promise.defer();
286 let resolver = function(subject) {
287 Services.obs.removeObserver(resolver, topic);
288 deferred.resolve(subject);
289 };
290 Services.obs.addObserver(resolver, topic, ownsWeak);
291 return deferred.promise;
292 };
293
294 /**
295 * Takes a function that uses a callback as its last parameter, and returns a
296 * new function that returns a promise instead
297 */
298 helpers.promiseify = function(functionWithLastParamCallback, scope) {
299 return function() {
300 let deferred = promise.defer();
301
302 let args = [].slice.call(arguments);
303 args.push(function(callbackParam) {
304 deferred.resolve(callbackParam);
305 });
306
307 try {
308 functionWithLastParamCallback.apply(scope, args);
309 }
310 catch (ex) {
311 deferred.resolve(ex);
312 }
313
314 return deferred.promise;
315 }
316 };
317
318 /**
319 * Warning: For use with Firefox Mochitests only.
320 *
321 * As addTab, but that also opens the developer toolbar. In addition a new
322 * 'automator' property is added to the options object with the display from GCLI
323 * in the developer toolbar
324 */
325 helpers.addTabWithToolbar = function(url, callback, options) {
326 return helpers.addTab(url, function(innerOptions) {
327 var win = innerOptions.chromeWindow;
328
329 return win.DeveloperToolbar.show(true).then(function() {
330 var display = win.DeveloperToolbar.display;
331 innerOptions.automator = createFFDisplayAutomator(display);
332 innerOptions.requisition = display.requisition;
333
334 var reply = callback.call(null, innerOptions);
335
336 return promise.resolve(reply).then(null, function(error) {
337 ok(false, error);
338 console.error(error);
339 }).then(function() {
340 win.DeveloperToolbar.hide().then(function() {
341 delete innerOptions.automator;
342 });
343 });
344 });
345 }, options);
346 };
347
348 /**
349 * Warning: For use with Firefox Mochitests only.
350 *
351 * Run a set of test functions stored in the values of the 'exports' object
352 * functions stored under setup/shutdown will be run at the start/end of the
353 * sequence of tests.
354 * A test will be considered finished when its return value is resolved.
355 * @param options An object to be passed to the test functions
356 * @param tests An object containing named test functions
357 * @return a promise which will be resolved when all tests have been run and
358 * their return values resolved
359 */
360 helpers.runTests = function(options, tests) {
361 var testNames = Object.keys(tests).filter(function(test) {
362 return test != "setup" && test != "shutdown";
363 });
364
365 var recover = function(error) {
366 ok(false, error);
367 console.error(error);
368 };
369
370 info("SETUP");
371 var setupDone = (tests.setup != null) ?
372 promise.resolve(tests.setup(options)) :
373 promise.resolve();
374
375 var testDone = setupDone.then(function() {
376 return util.promiseEach(testNames, function(testName) {
377 info(testName);
378 var action = tests[testName];
379
380 if (typeof action === "function") {
381 var reply = action.call(tests, options);
382 return promise.resolve(reply);
383 }
384 else if (Array.isArray(action)) {
385 return helpers.audit(options, action);
386 }
387
388 return promise.reject("test action '" + testName +
389 "' is not a function or helpers.audit() object");
390 });
391 }, recover);
392
393 return testDone.then(function() {
394 info("SHUTDOWN");
395 return (tests.shutdown != null) ?
396 promise.resolve(tests.shutdown(options)) :
397 promise.resolve();
398 }, recover);
399 };
400
401 ///////////////////////////////////////////////////////////////////////////////
402
403 /**
404 * Ensure that the options object is setup correctly
405 * options should contain an automator object that looks like this:
406 * {
407 * getInputState: function() { ... },
408 * setCursor: function(cursor) { ... },
409 * getCompleterTemplateData: function() { ... },
410 * focus: function() { ... },
411 * getErrorMessage: function() { ... },
412 * fakeKey: function(keyCode) { ... },
413 * setInput: function(typed) { ... },
414 * focusManager: ...,
415 * field: ...,
416 * }
417 */
418 function checkOptions(options) {
419 if (options == null) {
420 console.trace();
421 throw new Error('Missing options object');
422 }
423 if (options.requisition == null) {
424 console.trace();
425 throw new Error('options.requisition == null');
426 }
427 }
428
429 /**
430 * Various functions to return the actual state of the command line
431 */
432 helpers._actual = {
433 input: function(options) {
434 return options.automator.getInputState().typed;
435 },
436
437 hints: function(options) {
438 return options.automator.getCompleterTemplateData().then(function(data) {
439 var emptyParams = data.emptyParameters.join('');
440 return (data.directTabText + emptyParams + data.arrowTabText)
441 .replace(/\u00a0/g, ' ')
442 .replace(/\u21E5/, '->')
443 .replace(/ $/, '');
444 });
445 },
446
447 markup: function(options) {
448 var cursor = helpers._actual.cursor(options);
449 var statusMarkup = options.requisition.getInputStatusMarkup(cursor);
450 return statusMarkup.map(function(s) {
451 return new Array(s.string.length + 1).join(s.status.toString()[0]);
452 }).join('');
453 },
454
455 cursor: function(options) {
456 return options.automator.getInputState().cursor.start;
457 },
458
459 current: function(options) {
460 var cursor = helpers._actual.cursor(options);
461 return options.requisition.getAssignmentAt(cursor).param.name;
462 },
463
464 status: function(options) {
465 return options.requisition.status.toString();
466 },
467
468 predictions: function(options) {
469 var cursor = helpers._actual.cursor(options);
470 var assignment = options.requisition.getAssignmentAt(cursor);
471 var context = options.requisition.executionContext;
472 return assignment.getPredictions(context).then(function(predictions) {
473 return predictions.map(function(prediction) {
474 return prediction.name;
475 });
476 });
477 },
478
479 unassigned: function(options) {
480 return options.requisition._unassigned.map(function(assignment) {
481 return assignment.arg.toString();
482 }.bind(this));
483 },
484
485 outputState: function(options) {
486 var outputData = options.automator.focusManager._shouldShowOutput();
487 return outputData.visible + ':' + outputData.reason;
488 },
489
490 tooltipState: function(options) {
491 var tooltipData = options.automator.focusManager._shouldShowTooltip();
492 return tooltipData.visible + ':' + tooltipData.reason;
493 },
494
495 options: function(options) {
496 if (options.automator.field.menu == null) {
497 return [];
498 }
499 return options.automator.field.menu.items.map(function(item) {
500 return item.name.textContent ? item.name.textContent : item.name;
501 });
502 },
503
504 message: function(options) {
505 return options.automator.getErrorMessage();
506 }
507 };
508
509 function shouldOutputUnquoted(value) {
510 var type = typeof value;
511 return value == null || type === 'boolean' || type === 'number';
512 }
513
514 function outputArray(array) {
515 return (array.length === 0) ?
516 '[ ]' :
517 '[ \'' + array.join('\', \'') + '\' ]';
518 }
519
520 helpers._createDebugCheck = function(options) {
521 checkOptions(options);
522 var requisition = options.requisition;
523 var command = requisition.commandAssignment.value;
524 var cursor = helpers._actual.cursor(options);
525 var input = helpers._actual.input(options);
526 var padding = new Array(input.length + 1).join(' ');
527
528 var hintsPromise = helpers._actual.hints(options);
529 var predictionsPromise = helpers._actual.predictions(options);
530
531 return promise.all(hintsPromise, predictionsPromise).then(function(values) {
532 var hints = values[0];
533 var predictions = values[1];
534 var output = '';
535
536 output += 'return helpers.audit(options, [\n';
537 output += ' {\n';
538
539 if (cursor === input.length) {
540 output += ' setup: \'' + input + '\',\n';
541 }
542 else {
543 output += ' name: \'' + input + ' (cursor=' + cursor + ')\',\n';
544 output += ' setup: function() {\n';
545 output += ' return helpers.setInput(options, \'' + input + '\', ' + cursor + ');\n';
546 output += ' },\n';
547 }
548
549 output += ' check: {\n';
550
551 output += ' input: \'' + input + '\',\n';
552 output += ' hints: ' + padding + '\'' + hints + '\',\n';
553 output += ' markup: \'' + helpers._actual.markup(options) + '\',\n';
554 output += ' cursor: ' + cursor + ',\n';
555 output += ' current: \'' + helpers._actual.current(options) + '\',\n';
556 output += ' status: \'' + helpers._actual.status(options) + '\',\n';
557 output += ' options: ' + outputArray(helpers._actual.options(options)) + ',\n';
558 output += ' message: \'' + helpers._actual.message(options) + '\',\n';
559 output += ' predictions: ' + outputArray(predictions) + ',\n';
560 output += ' unassigned: ' + outputArray(requisition._unassigned) + ',\n';
561 output += ' outputState: \'' + helpers._actual.outputState(options) + '\',\n';
562 output += ' tooltipState: \'' + helpers._actual.tooltipState(options) + '\'' +
563 (command ? ',' : '') +'\n';
564
565 if (command) {
566 output += ' args: {\n';
567 output += ' command: { name: \'' + command.name + '\' },\n';
568
569 requisition.getAssignments().forEach(function(assignment) {
570 output += ' ' + assignment.param.name + ': { ';
571
572 if (typeof assignment.value === 'string') {
573 output += 'value: \'' + assignment.value + '\', ';
574 }
575 else if (shouldOutputUnquoted(assignment.value)) {
576 output += 'value: ' + assignment.value + ', ';
577 }
578 else {
579 output += '/*value:' + assignment.value + ',*/ ';
580 }
581
582 output += 'arg: \'' + assignment.arg + '\', ';
583 output += 'status: \'' + assignment.getStatus().toString() + '\', ';
584 output += 'message: \'' + assignment.message + '\'';
585 output += ' },\n';
586 });
587
588 output += ' }\n';
589 }
590
591 output += ' },\n';
592 output += ' exec: {\n';
593 output += ' output: \'\',\n';
594 output += ' type: \'string\',\n';
595 output += ' error: false\n';
596 output += ' }\n';
597 output += ' }\n';
598 output += ']);';
599
600 return output;
601 }.bind(this), util.errorHandler);
602 };
603
604 /**
605 * Simulate focusing the input field
606 */
607 helpers.focusInput = function(options) {
608 checkOptions(options);
609 options.automator.focus();
610 };
611
612 /**
613 * Simulate pressing TAB in the input field
614 */
615 helpers.pressTab = function(options) {
616 checkOptions(options);
617 return helpers.pressKey(options, KeyEvent.DOM_VK_TAB);
618 };
619
620 /**
621 * Simulate pressing RETURN in the input field
622 */
623 helpers.pressReturn = function(options) {
624 checkOptions(options);
625 return helpers.pressKey(options, KeyEvent.DOM_VK_RETURN);
626 };
627
628 /**
629 * Simulate pressing a key by keyCode in the input field
630 */
631 helpers.pressKey = function(options, keyCode) {
632 checkOptions(options);
633 return options.automator.fakeKey(keyCode);
634 };
635
636 /**
637 * A list of special key presses and how to to them, for the benefit of
638 * helpers.setInput
639 */
640 var ACTIONS = {
641 '<TAB>': function(options) {
642 return helpers.pressTab(options);
643 },
644 '<RETURN>': function(options) {
645 return helpers.pressReturn(options);
646 },
647 '<UP>': function(options) {
648 return helpers.pressKey(options, KeyEvent.DOM_VK_UP);
649 },
650 '<DOWN>': function(options) {
651 return helpers.pressKey(options, KeyEvent.DOM_VK_DOWN);
652 },
653 '<BACKSPACE>': function(options) {
654 return helpers.pressKey(options, KeyEvent.DOM_VK_BACK_SPACE);
655 }
656 };
657
658 /**
659 * Used in helpers.setInput to cut an input string like 'blah<TAB>foo<UP>' into
660 * an array like [ 'blah', '<TAB>', 'foo', '<UP>' ].
661 * When using this RegExp, you also need to filter out the blank strings.
662 */
663 var CHUNKER = /([^<]*)(<[A-Z]+>)/;
664
665 /**
666 * Alter the input to <code>typed</code> optionally leaving the cursor at
667 * <code>cursor</code>.
668 * @return A promise of the number of key-presses to respond
669 */
670 helpers.setInput = function(options, typed, cursor) {
671 checkOptions(options);
672 var inputPromise;
673 var automator = options.automator;
674 // We try to measure average keypress time, but setInput can simulate
675 // several, so we try to keep track of how many
676 var chunkLen = 1;
677
678 // The easy case is a simple string without things like <TAB>
679 if (typed.indexOf('<') === -1) {
680 inputPromise = automator.setInput(typed);
681 }
682 else {
683 // Cut the input up into input strings separated by '<KEY>' tokens. The
684 // CHUNKS RegExp leaves blanks so we filter them out.
685 var chunks = typed.split(CHUNKER).filter(function(s) {
686 return s !== '';
687 });
688 chunkLen = chunks.length + 1;
689
690 // We're working on this in chunks so first clear the input
691 inputPromise = automator.setInput('').then(function() {
692 return util.promiseEach(chunks, function(chunk) {
693 if (chunk.charAt(0) === '<') {
694 var action = ACTIONS[chunk];
695 if (typeof action !== 'function') {
696 console.error('Known actions: ' + Object.keys(ACTIONS).join());
697 throw new Error('Key action not found "' + chunk + '"');
698 }
699 return action(options);
700 }
701 else {
702 return automator.setInput(automator.getInputState().typed + chunk);
703 }
704 });
705 });
706 }
707
708 return inputPromise.then(function() {
709 if (cursor != null) {
710 automator.setCursor({ start: cursor, end: cursor });
711 }
712
713 if (automator.focusManager) {
714 automator.focusManager.onInputChange();
715 }
716
717 // Firefox testing is noisy and distant, so logging helps
718 if (options.isFirefox) {
719 var cursorStr = (cursor == null ? '' : ', ' + cursor);
720 log('setInput("' + typed + '"' + cursorStr + ')');
721 }
722
723 return chunkLen;
724 });
725 };
726
727 /**
728 * Helper for helpers.audit() to ensure that all the 'check' properties match.
729 * See helpers.audit for more information.
730 * @param name The name to use in error messages
731 * @param checks See helpers.audit for a list of available checks
732 * @return A promise which resolves to undefined when the checks are complete
733 */
734 helpers._check = function(options, name, checks) {
735 // A test method to check that all args are assigned in some way
736 var requisition = options.requisition;
737 requisition._args.forEach(function(arg) {
738 if (arg.assignment == null) {
739 assert.ok(false, 'No assignment for ' + arg);
740 }
741 });
742
743 if (checks == null) {
744 return promise.resolve();
745 }
746
747 var outstanding = [];
748 var suffix = name ? ' (for \'' + name + '\')' : '';
749
750 if (!options.isNoDom && 'input' in checks) {
751 assert.is(helpers._actual.input(options), checks.input, 'input' + suffix);
752 }
753
754 if (!options.isNoDom && 'cursor' in checks) {
755 assert.is(helpers._actual.cursor(options), checks.cursor, 'cursor' + suffix);
756 }
757
758 if (!options.isNoDom && 'current' in checks) {
759 assert.is(helpers._actual.current(options), checks.current, 'current' + suffix);
760 }
761
762 if ('status' in checks) {
763 assert.is(helpers._actual.status(options), checks.status, 'status' + suffix);
764 }
765
766 if (!options.isNoDom && 'markup' in checks) {
767 assert.is(helpers._actual.markup(options), checks.markup, 'markup' + suffix);
768 }
769
770 if (!options.isNoDom && 'hints' in checks) {
771 var hintCheck = function(actualHints) {
772 assert.is(actualHints, checks.hints, 'hints' + suffix);
773 };
774 outstanding.push(helpers._actual.hints(options).then(hintCheck));
775 }
776
777 if (!options.isNoDom && 'predictions' in checks) {
778 var predictionsCheck = function(actualPredictions) {
779 helpers.arrayIs(actualPredictions,
780 checks.predictions,
781 'predictions' + suffix);
782 };
783 outstanding.push(helpers._actual.predictions(options).then(predictionsCheck));
784 }
785
786 if (!options.isNoDom && 'predictionsContains' in checks) {
787 var containsCheck = function(actualPredictions) {
788 checks.predictionsContains.forEach(function(prediction) {
789 var index = actualPredictions.indexOf(prediction);
790 assert.ok(index !== -1,
791 'predictionsContains:' + prediction + suffix);
792 });
793 };
794 outstanding.push(helpers._actual.predictions(options).then(containsCheck));
795 }
796
797 if ('unassigned' in checks) {
798 helpers.arrayIs(helpers._actual.unassigned(options),
799 checks.unassigned,
800 'unassigned' + suffix);
801 }
802
803 /* TODO: Fix this
804 if (!options.isNoDom && 'tooltipState' in checks) {
805 assert.is(helpers._actual.tooltipState(options),
806 checks.tooltipState,
807 'tooltipState' + suffix);
808 }
809 */
810
811 if (!options.isNoDom && 'outputState' in checks) {
812 assert.is(helpers._actual.outputState(options),
813 checks.outputState,
814 'outputState' + suffix);
815 }
816
817 if (!options.isNoDom && 'options' in checks) {
818 helpers.arrayIs(helpers._actual.options(options),
819 checks.options,
820 'options' + suffix);
821 }
822
823 if (!options.isNoDom && 'error' in checks) {
824 assert.is(helpers._actual.message(options), checks.error, 'error' + suffix);
825 }
826
827 if (checks.args != null) {
828 Object.keys(checks.args).forEach(function(paramName) {
829 var check = checks.args[paramName];
830
831 // We allow an 'argument' called 'command' to be the command itself, but
832 // what if the command has a parameter called 'command' (for example, an
833 // 'exec' command)? We default to using the parameter because checking
834 // the command value is less useful
835 var assignment = requisition.getAssignment(paramName);
836 if (assignment == null && paramName === 'command') {
837 assignment = requisition.commandAssignment;
838 }
839
840 if (assignment == null) {
841 assert.ok(false, 'Unknown arg: ' + paramName + suffix);
842 return;
843 }
844
845 if ('value' in check) {
846 if (typeof check.value === 'function') {
847 try {
848 check.value(assignment.value);
849 }
850 catch (ex) {
851 assert.ok(false, '' + ex);
852 }
853 }
854 else {
855 assert.is(assignment.value,
856 check.value,
857 'arg.' + paramName + '.value' + suffix);
858 }
859 }
860
861 if ('name' in check) {
862 assert.is(assignment.value.name,
863 check.name,
864 'arg.' + paramName + '.name' + suffix);
865 }
866
867 if ('type' in check) {
868 assert.is(assignment.arg.type,
869 check.type,
870 'arg.' + paramName + '.type' + suffix);
871 }
872
873 if ('arg' in check) {
874 assert.is(assignment.arg.toString(),
875 check.arg,
876 'arg.' + paramName + '.arg' + suffix);
877 }
878
879 if ('status' in check) {
880 assert.is(assignment.getStatus().toString(),
881 check.status,
882 'arg.' + paramName + '.status' + suffix);
883 }
884
885 if (!options.isNoDom && 'message' in check) {
886 if (typeof check.message.test === 'function') {
887 assert.ok(check.message.test(assignment.message),
888 'arg.' + paramName + '.message' + suffix);
889 }
890 else {
891 assert.is(assignment.message,
892 check.message,
893 'arg.' + paramName + '.message' + suffix);
894 }
895 }
896 });
897 }
898
899 return promise.all(outstanding).then(function() {
900 // Ensure the promise resolves to nothing
901 return undefined;
902 });
903 };
904
905 /**
906 * Helper for helpers.audit() to ensure that all the 'exec' properties work.
907 * See helpers.audit for more information.
908 * @param name The name to use in error messages
909 * @param expected See helpers.audit for a list of available exec checks
910 * @return A promise which resolves to undefined when the checks are complete
911 */
912 helpers._exec = function(options, name, expected) {
913 var requisition = options.requisition;
914 if (expected == null) {
915 return promise.resolve({});
916 }
917
918 var origLogErrors = cli.logErrors;
919 if (expected.error) {
920 cli.logErrors = false;
921 }
922
923 try {
924 return requisition.exec({ hidden: true }).then(function(output) {
925 if ('type' in expected) {
926 assert.is(output.type,
927 expected.type,
928 'output.type for: ' + name);
929 }
930
931 if ('error' in expected) {
932 assert.is(output.error,
933 expected.error,
934 'output.error for: ' + name);
935 }
936
937 if (!('output' in expected)) {
938 return { output: output };
939 }
940
941 var context = requisition.conversionContext;
942 var convertPromise;
943 if (options.isNoDom) {
944 convertPromise = output.convert('string', context);
945 }
946 else {
947 convertPromise = output.convert('dom', context).then(function(node) {
948 return node.textContent.trim();
949 });
950 }
951
952 return convertPromise.then(function(textOutput) {
953 var doTest = function(match, against) {
954 // Only log the real textContent if the test fails
955 if (against.match(match) != null) {
956 assert.ok(true, 'html output for \'' + name + '\' ' +
957 'should match /' + (match.source || match) + '/');
958 } else {
959 assert.ok(false, 'html output for \'' + name + '\' ' +
960 'should match /' + (match.source || match) + '/. ' +
961 'Actual textContent: "' + against + '"');
962 }
963 };
964
965 if (typeof expected.output === 'string') {
966 assert.is(textOutput,
967 expected.output,
968 'html output for ' + name);
969 }
970 else if (Array.isArray(expected.output)) {
971 expected.output.forEach(function(match) {
972 doTest(match, textOutput);
973 });
974 }
975 else {
976 doTest(expected.output, textOutput);
977 }
978
979 if (expected.error) {
980 cli.logErrors = origLogErrors;
981 }
982 return { output: output, text: textOutput };
983 });
984 }.bind(this)).then(function(data) {
985 if (expected.error) {
986 cli.logErrors = origLogErrors;
987 }
988
989 return data;
990 });
991 }
992 catch (ex) {
993 assert.ok(false, 'Failure executing \'' + name + '\': ' + ex);
994 util.errorHandler(ex);
995
996 if (expected.error) {
997 cli.logErrors = origLogErrors;
998 }
999 return promise.resolve({});
1000 }
1001 };
1002
1003 /**
1004 * Helper to setup the test
1005 */
1006 helpers._setup = function(options, name, audit) {
1007 if (typeof audit.setup === 'string') {
1008 return helpers.setInput(options, audit.setup);
1009 }
1010
1011 if (typeof audit.setup === 'function') {
1012 return promise.resolve(audit.setup.call(audit));
1013 }
1014
1015 return promise.reject('\'setup\' property must be a string or a function. Is ' + audit.setup);
1016 };
1017
1018 /**
1019 * Helper to shutdown the test
1020 */
1021 helpers._post = function(name, audit, data) {
1022 if (typeof audit.post === 'function') {
1023 return promise.resolve(audit.post.call(audit, data.output, data.text));
1024 }
1025 return promise.resolve(audit.post);
1026 };
1027
1028 /*
1029 * We do some basic response time stats so we can see if we're getting slow
1030 */
1031 var totalResponseTime = 0;
1032 var averageOver = 0;
1033 var maxResponseTime = 0;
1034 var maxResponseCulprit;
1035 var start;
1036
1037 /**
1038 * Restart the stats collection process
1039 */
1040 helpers.resetResponseTimes = function() {
1041 start = new Date().getTime();
1042 totalResponseTime = 0;
1043 averageOver = 0;
1044 maxResponseTime = 0;
1045 maxResponseCulprit = undefined;
1046 };
1047
1048 /**
1049 * Expose an average response time in milliseconds
1050 */
1051 Object.defineProperty(helpers, 'averageResponseTime', {
1052 get: function() {
1053 return averageOver === 0 ?
1054 undefined :
1055 Math.round(100 * totalResponseTime / averageOver) / 100;
1056 },
1057 enumerable: true
1058 });
1059
1060 /**
1061 * Expose a maximum response time in milliseconds
1062 */
1063 Object.defineProperty(helpers, 'maxResponseTime', {
1064 get: function() { return Math.round(maxResponseTime * 100) / 100; },
1065 enumerable: true
1066 });
1067
1068 /**
1069 * Expose the name of the test that provided the maximum response time
1070 */
1071 Object.defineProperty(helpers, 'maxResponseCulprit', {
1072 get: function() { return maxResponseCulprit; },
1073 enumerable: true
1074 });
1075
1076 /**
1077 * Quick summary of the times
1078 */
1079 Object.defineProperty(helpers, 'timingSummary', {
1080 get: function() {
1081 var elapsed = (new Date().getTime() - start) / 1000;
1082 return 'Total ' + elapsed + 's, ' +
1083 'ave response ' + helpers.averageResponseTime + 'ms, ' +
1084 'max response ' + helpers.maxResponseTime + 'ms ' +
1085 'from \'' + helpers.maxResponseCulprit + '\'';
1086 },
1087 enumerable: true
1088 });
1089
1090 /**
1091 * A way of turning a set of tests into something more declarative, this helps
1092 * to allow tests to be asynchronous.
1093 * @param audits An array of objects each of which contains:
1094 * - setup: string/function to be called to set the test up.
1095 * If audit is a string then it is passed to helpers.setInput().
1096 * If audit is a function then it is executed. The tests will wait while
1097 * tests that return promises complete.
1098 * - name: For debugging purposes. If name is undefined, and 'setup'
1099 * is a string then the setup value will be used automatically
1100 * - skipIf: A function to define if the test should be skipped. Useful for
1101 * excluding tests from certain environments (e.g. nodom, firefox, etc).
1102 * The name of the test will be used in log messages noting the skip
1103 * See helpers.reason for pre-defined skip functions. The skip function must
1104 * be synchronous, and will be passed the test options object.
1105 * - skipRemainingIf: A function to skip all the remaining audits in this set.
1106 * See skipIf for details of how skip functions work.
1107 * - check: Check data. Available checks:
1108 * - input: The text displayed in the input field
1109 * - cursor: The position of the start of the cursor
1110 * - status: One of 'VALID', 'ERROR', 'INCOMPLETE'
1111 * - hints: The hint text, i.e. a concatenation of the directTabText, the
1112 * emptyParameters and the arrowTabText. The text as inserted into the UI
1113 * will include NBSP and Unicode RARR characters, these should be
1114 * represented using normal space and '->' for the arrow
1115 * - markup: What state should the error markup be in. e.g. 'VVVIIIEEE'
1116 * - args: Maps of checks to make against the arguments:
1117 * - value: i.e. assignment.value (which ignores defaultValue)
1118 * - type: Argument/BlankArgument/MergedArgument/etc i.e. what's assigned
1119 * Care should be taken with this since it's something of an
1120 * implementation detail
1121 * - arg: The toString value of the argument
1122 * - status: i.e. assignment.getStatus
1123 * - message: i.e. assignment.message
1124 * - name: For commands - checks assignment.value.name
1125 * - exec: Object to indicate we should execute the command and check the
1126 * results. Available checks:
1127 * - output: A string, RegExp or array of RegExps to compare with the output
1128 * If typeof output is a string then the output should be exactly equal
1129 * to the given string. If the type of output is a RegExp or array of
1130 * RegExps then the output should match all RegExps
1131 * - post: Function to be called after the checks have been run
1132 */
1133 helpers.audit = function(options, audits) {
1134 checkOptions(options);
1135 var skipReason = null;
1136 return util.promiseEach(audits, function(audit) {
1137 var name = audit.name;
1138 if (name == null && typeof audit.setup === 'string') {
1139 name = audit.setup;
1140 }
1141
1142 if (assert.testLogging) {
1143 log('- START \'' + name + '\' in ' + assert.currentTest);
1144 }
1145
1146 if (audit.skipRemainingIf) {
1147 var skipRemainingIf = (typeof audit.skipRemainingIf === 'function') ?
1148 audit.skipRemainingIf(options) :
1149 !!audit.skipRemainingIf;
1150 if (skipRemainingIf) {
1151 skipReason = audit.skipRemainingIf.name ?
1152 'due to ' + audit.skipRemainingIf.name :
1153 '';
1154 assert.log('Skipped ' + name + ' ' + skipReason);
1155 return promise.resolve(undefined);
1156 }
1157 }
1158
1159 if (audit.skipIf) {
1160 var skip = (typeof audit.skipIf === 'function') ?
1161 audit.skipIf(options) :
1162 !!audit.skipIf;
1163 if (skip) {
1164 var reason = audit.skipIf.name ? 'due to ' + audit.skipIf.name : '';
1165 assert.log('Skipped ' + name + ' ' + reason);
1166 return promise.resolve(undefined);
1167 }
1168 }
1169
1170 if (skipReason != null) {
1171 assert.log('Skipped ' + name + ' ' + skipReason);
1172 return promise.resolve(undefined);
1173 }
1174
1175 var start = new Date().getTime();
1176
1177 var setupDone = helpers._setup(options, name, audit);
1178 return setupDone.then(function(chunkLen) {
1179 if (typeof chunkLen !== 'number') {
1180 chunkLen = 1;
1181 }
1182
1183 // Nasty hack to allow us to auto-skip tests where we're actually testing
1184 // a key-sequence (i.e. targeting terminal.js) when there is no terminal
1185 if (chunkLen === -1) {
1186 assert.log('Skipped ' + name + ' ' + skipReason);
1187 return promise.resolve(undefined);
1188 }
1189
1190 if (assert.currentTest) {
1191 var responseTime = (new Date().getTime() - start) / chunkLen;
1192 totalResponseTime += responseTime;
1193 if (responseTime > maxResponseTime) {
1194 maxResponseTime = responseTime;
1195 maxResponseCulprit = assert.currentTest + '/' + name;
1196 }
1197 averageOver++;
1198 }
1199
1200 var checkDone = helpers._check(options, name, audit.check);
1201 return checkDone.then(function() {
1202 var execDone = helpers._exec(options, name, audit.exec);
1203 return execDone.then(function(data) {
1204 return helpers._post(name, audit, data).then(function() {
1205 if (assert.testLogging) {
1206 log('- END \'' + name + '\' in ' + assert.currentTest);
1207 }
1208 });
1209 });
1210 });
1211 });
1212 }).then(function() {
1213 return options.automator.setInput('');
1214 });
1215 };
1216
1217 /**
1218 * Compare 2 arrays.
1219 */
1220 helpers.arrayIs = function(actual, expected, message) {
1221 assert.ok(Array.isArray(actual), 'actual is not an array: ' + message);
1222 assert.ok(Array.isArray(expected), 'expected is not an array: ' + message);
1223
1224 if (!Array.isArray(actual) || !Array.isArray(expected)) {
1225 return;
1226 }
1227
1228 assert.is(actual.length, expected.length, 'array length: ' + message);
1229
1230 for (var i = 0; i < actual.length && i < expected.length; i++) {
1231 assert.is(actual[i], expected[i], 'member[' + i + ']: ' + message);
1232 }
1233 };
1234
1235 /**
1236 * A quick helper to log to the correct place
1237 */
1238 function log(message) {
1239 if (typeof info === 'function') {
1240 info(message);
1241 }
1242 else {
1243 console.log(message);
1244 }
1245 }

mercurial