├── .gitignore ├── .gitmodules ├── .npmignore ├── History.md ├── Makefile ├── Readme.md ├── api.html ├── examples ├── google.js ├── google.nested.js └── learnboost.js ├── index.js ├── lib └── soda │ ├── client.js │ ├── index.js │ └── sauce.js ├── package.json └── test ├── client.test.js ├── sauce.test.js └── soda.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | test.js -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "support/expresso"] 2 | path = support/expresso 3 | url = git://github.com/visionmedia/expresso.git 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | support 3 | examples 4 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 0.2.5 / 2011-12-03 3 | ================== 4 | 5 | * Adding main job url getter to sauce client 6 | * Add 'selectAndWait' command 7 | * Do not ignore timeouts. 8 | * Fixed chaining to let users send further commands during chain end 9 | * Added support job URL generation 10 | 11 | 0.2.4 / 2011-05-27 12 | ================== 13 | 14 | * Added support for the `store` accessor 15 | * Fixed case with very long url by utilizing a urlencoded POST request [tszming] 16 | 17 | 0.2.3 / 2011-03-28 18 | ================== 19 | 20 | * Added `captureNetworkTraffic` 21 | 22 | 0.2.2 / 2011-02-25 23 | ================== 24 | 25 | * Use ondemand.saucelabs.com:80 by default [epall] 26 | 27 | 0.2.1 / 2011-02-03 28 | ================== 29 | 30 | * Added `captureScreenshotToString` to command list 31 | 32 | 0.2.0 / 2010-10-12 33 | ================== 34 | 35 | * Added `.and()` (see docs) 36 | * Added several commands [dshaw] 37 | * Fixed `selectStoreOptions` typo [dshaw] 38 | 39 | 0.1.0 / 2010-10-07 40 | ================== 41 | 42 | * Added `Client#and()` to provide an arbitrary callback [suggested by dshaw / scoates] 43 | 44 | 0.0.2 / 2010-09-16 45 | ================== 46 | 47 | * Added saucelabs environment variables [Adam Christian] 48 | * Added Adam Christian to the authors list 49 | 50 | 0.0.1 / 2010-09-16 51 | ================== 52 | 53 | * Initial release 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | test: 3 | @./support/expresso/bin/expresso \ 4 | -I lib 5 | 6 | docs: 7 | dox \ 8 | --title "Soda" \ 9 | --desc "The _Selenium Node Adapter_ or __Soda__ provides a unified client to both [Selenium RC](http://seleniumhq.org/projects/remote-control/) and [Sauce Labs OnDemand](http://saucelabs.com/ondemand)." \ 10 | --ribbon "http://github.com/learnboost/soda" \ 11 | --private \ 12 | lib/soda/*.js > api.html 13 | 14 | docclean: 15 | rm -f api.html 16 | 17 | .PHONY: test docs docclean 18 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Soda 2 | 3 | Selenium Node Adapter. A light-weight Selenium RC client for [NodeJS](http://nodejs.org), with additional [Sauce Labs](http://saucelabs.com) integration for acceptance testing in the cloud. 4 | 5 | ## Installation 6 | 7 | via npm: 8 | 9 | $ npm install soda 10 | 11 | ## Authors 12 | 13 | - TJ Holowaychuk ([visionmedia](http://github.com/visionmedia)) 14 | - Adam Christian ([admc](http://github.com/admc)) 15 | - Daniel Shaw ([dshaw](http://github.com/dshaw)) 16 | 17 | ## Running Examples 18 | 19 | The examples provided in _./examples_ are intended to be run against Selenium RC, which can be downloaded [here](http://seleniumhq.org/projects/remote-control/). Once installed simply execute the following command to start the selenium server: 20 | 21 | $ java -jar selenium-server.jar 22 | 23 | Then choose an example to run using soda: 24 | 25 | $ node examples/google.js 26 | 27 | ## Actions 28 | 29 | "Selenese" actions include commands such as _open_ and _type_. Every action has a corresponding `Client` method which accept a variable number of arguments followed by a callback `Function` which receives any potential `err`, the response `body`, and `response` object itself. 30 | 31 | browser.session(function(err){ 32 | browser.open('/', function(err, body, res){ 33 | browser.type('q', 'Hello World', function(err, body, res){ 34 | browser.testComplete(function(){ 35 | 36 | }); 37 | }); 38 | }); 39 | }); 40 | 41 | Because nested callbacks can quickly become overwhelming, Soda has optional chaining support by simply utilizing the `.chain` getter as shown below. If an exception is thrown in a callback, or a command fails then it will be passed to `end(err)`. The `.chain` getter should only be used once, activating the chaining api. 42 | 43 | browser 44 | .chain 45 | .session() 46 | .open('/') 47 | .type('q', 'Hello World') 48 | .end(function(err){ 49 | browser.testComplete(function() { 50 | console.log('done'); 51 | if(err) throw err; 52 | }); 53 | }); 54 | 55 | When chaining successful commands may receive a callback, which is useful for custom assertions: 56 | 57 | browser 58 | .chain 59 | .session() 60 | .open('/') 61 | .getTitle(function(title){ 62 | assert.equal('Hello World', title); 63 | }) 64 | .end(function(err){ 65 | browser.testComplete(function() { 66 | console.log('done'); 67 | if(err) throw err; 68 | }); 69 | }) 70 | 71 | With the `.and()` method you can add additional commands to the queue. The callback accepts the client instance, which is also the value of "this". 72 | 73 | For example you may want to authenticate a user, note we do _not_ use `.chain` or `.end()` again, this simply extends the current queue. 74 | 75 | function login(user, pass) { 76 | return function(browser) { 77 | browser 78 | .open('/login') 79 | .type('username', name) 80 | .type('password', pass) 81 | .clickAndWait('login'); 82 | } 83 | } 84 | 85 | With this helper function we can now re-use this logic in several places, and express the tests in a more logical manner. 86 | 87 | browser 88 | .chain 89 | .session() 90 | .open('/') 91 | .assertTitle('Something') 92 | .and(login('foo', 'bar')) 93 | .assertTitle('Foobar') 94 | .and(login('someone', 'else')) 95 | .assertTitle('Someone else') 96 | .end(function(err){ 97 | browser.testComplete(function() { 98 | console.log('done'); 99 | if(err) throw err; 100 | }); 101 | }); 102 | 103 | ## Sauce Labs Videos & Logs 104 | 105 | When a job is complete, you can request the log or flv video from Sauce Labs. To access the url for these resources you may use `SauceClient#videoUrl` or `SauceClient#logUrl`, for example: 106 | 107 | ... 108 | .end(function(err){ 109 | console.log(this.jobUrl) 110 | console.log(this.videoUrl) 111 | console.log(this.logUrl) 112 | }) 113 | 114 | Sauce Labs also provides a script that you may embed in your CI server to display the video, accessible via `SauceClient#video`, which will yield something similar to: 115 | 116 | 5 | 99 | 109 | 110 | 111 | 112 | 116 | 121 | 122 | 123 | 134 | 149 | 150 | 151 | 155 | 158 | 159 | 160 | 167 | 184 | 185 | 186 | 193 | 229 | 230 | 231 | 238 | 256 | 257 | 258 | 275 | 281 | 282 | 283 | 291 | 297 | 298 | 299 | 306 | 339 | 340 | 341 | 348 | 354 | 355 | 356 | 363 | 368 | 369 | 370 | 375 | 467 | 468 | 469 | 474 | 535 | 536 | 537 | 548 | 560 | 561 | 562 | 566 | 583 | 584 | 588 | 591 | 592 | 593 | 597 | 601 | 602 | 603 | 608 | 612 | 613 | 617 | 620 | 621 | 622 | 638 | 663 | 664 | 665 | 669 | 672 | 673 | 674 | 681 | 686 | 687 | 688 | 695 | 700 | 701 | 702 | 709 | 714 | 715 | 716 | 723 | 728 | 729 | 730 | 737 | 745 | 746 | 747 | 754 | 762 | 763 |

Soda

The Selenium Node Adapter or Soda provides a unified client to both Selenium RC and Saucelabs OnDemand.

client

lib/soda/client.js
113 |

Module dependencies. 114 |

115 |
117 |
var http = require('http')
118 |   , qs = require('querystring')
119 |   , EventEmitter = require('events').EventEmitter;
120 |
124 |

Initialize a Client with the given options.

125 | 126 |

Options

127 | 128 |
  • host Hostname defaulting to localhost
  • port Port number defaulting to 4444
  • browser Browser name
  • url URL string
129 | 130 |

131 | 132 |
  • params: Object options

  • api: public

133 |
135 |
var Client = exports = module.exports = function Client(options) {
136 |   this.host = options.host || 'localhost';
137 |   this.port = options.port || 4444;
138 |   this.browser = options.browser || 'firefox';
139 |   this.url = options.url;
140 | 
141 |   // Allow optional "*" prefix
142 |   if (this.browser[0] !== '*') {
143 |     this.browser = '*' + this.browser;
144 |   }
145 | 
146 |   EventEmitter.call(this);
147 | };
148 |
152 |

Interit from EventEmitter. 153 |

154 |
156 |
Client.prototype.__proto__ = EventEmitter.prototype;
157 |
161 |

Initialize a new session, then callback fn(err, sid)

162 | 163 |

164 | 165 |
  • param: Function fn

  • return: Client

  • api: public

166 |
168 |
Client.prototype.session = function(fn){
169 |   var self = this;
170 |   if (!this.browser) throw new Error('browser required');
171 |   if (!this.url) throw new Error('browser url required');
172 |   if (this.queue) {
173 |     return this.enqueue('getNewBrowserSession', [this.browser, this.url], function(body){
174 |       self.sid = body;
175 |     });
176 |   } else {
177 |     this.command('getNewBrowserSession', [this.browser, this.url], function(err, body){
178 |       if (err) return fn(err);
179 |       fn(null, self.sid = body);
180 |     });
181 |   }
182 | };
183 |
187 |

Execute the given cmd / args, then callback fn(err, body, res).

188 | 189 |

190 | 191 |
  • param: String cmd

  • param: Array args

  • param: Function fn

  • return: Client for chaining

  • api: private

192 |
194 |
Client.prototype.command = function(cmd, args, fn){
195 |   this.emit('command', cmd, args);
196 | 
197 |   // HTTP client
198 |   var client = http.createClient(this.port, this.host);
199 | 
200 |   // Path construction
201 |   var path = this.commandPath(cmd, args);
202 | 
203 |   // Request
204 |   var req = client.request('GET'
205 |     , path
206 |     , { Host: this.host + (this.port ? ':' + this.port : '') });
207 |     
208 |   req.on('response', function(res){
209 |     res.body = '';
210 |     res.setEncoding('utf8');
211 |     res.on('data', function(chunk){ res.body += chunk; });
212 |     res.on('end', function(){
213 |       if (res.body.indexOf('ERROR') === 0) {
214 |         var err = res.body.replace(/^ERROR: */, '');
215 |         err = cmd + '(' + args.join(', ') + '): ' + err; 
216 |         fn(new Error(err), res.body, res);
217 |       } else {
218 |         if (res.body.indexOf('OK') === 0) {
219 |           res.body = res.body.replace(/^OK,?/, '');
220 |         }
221 |         fn(null, res.body, res);
222 |       }
223 |     });
224 |   });
225 |   req.end();
226 |   return this;
227 | };
228 |
232 |

Construct a cmd path with the given args.

233 | 234 |

235 | 236 |
  • param: String name

  • param: Array args

  • return: String

  • api: private

237 |
239 |
Client.prototype.commandPath = function(cmd, args){
240 |   var obj = { cmd: cmd };
241 | 
242 |   // Arguments by nth
243 |   if (args) {
244 |     args.forEach(function(arg, i){
245 |       obj[i+1] = arg;
246 |     });
247 |   }
248 |   // Ignore session id for getNewBrowserSession
249 |   if (this.sid && cmd !== 'getNewBrowserSession') {
250 |     obj.sessionId = this.sid;
251 |   }
252 | 
253 |   return '/selenium-server/driver/?' + qs.stringify(obj);
254 | };
255 |
259 |

Indicate that commands should be queued.

260 | 261 |

Example

262 | 263 |
 browser
264 |    .chain
265 |    .session()
266 |    .open('/')
267 |    .type('q', 'Hello World')
268 |    .clickAndWait('btnG')
269 |    .assertTitle('Hello World - Google')
270 |    .testComplete()
271 |    .end(function(err){ ... });
272 | 273 |
  • api: public

274 |
276 |
Client.prototype.__defineGetter__('chain', function(){
277 |   this.queue = [];
278 |   return this;
279 | });
280 |
284 |

Callback fn(err) when the queue is complete, or 285 | when an exception has occurred.

286 | 287 |

288 | 289 |
  • param: Function fn

  • api: public

290 |
292 |
Client.prototype.end = function(fn){
293 |   this._done = fn;
294 |   this.queue.shift()();
295 | };
296 |
300 |

Enqueue the given cmd and array of args for execution.

301 | 302 |

303 | 304 |
  • param: String cmd

  • param: Array args

  • return: Client

  • api: private

305 |
307 |
Client.prototype.enqueue = function(cmd, args, fn){
308 |   var self = this
309 |     , len = args.length;
310 | 
311 |   // Indirect callback support
312 |   if (typeof args[len - 1] === 'function') {
313 |     fn = args.pop();
314 |   }
315 | 
316 |   this.queue.push(function(){
317 |     self.command(cmd, args, function(err, body, res){
318 |       // Callback support
319 |       if (!err && fn) {
320 |         try {
321 |           fn(body, res);
322 |         } catch (err) {
323 |           return self._done(err, body, res);
324 |         }
325 |       }
326 | 
327 |       if (err) {
328 |         self._done(err, body, res);
329 |       } else if (self.queue.length) {
330 |         self.queue.shift()();
331 |       } else {
332 |         self._done(null, body, res);
333 |       }
334 |     });
335 |   });
336 |   return this;
337 | };
338 |
342 |

Arbitrary callback fn(this) when using the chaining api.

343 | 344 |

345 | 346 |
  • param: Function fn

  • return: Client

  • api: public

347 |
349 |
Client.prototype.and = function(fn){
350 |   fn.call(this, this);
351 |   return this;
352 | };
353 |
357 |

Shortcut for new soda.Client().

358 | 359 |

360 | 361 |
  • param: Object options

  • return: Client

  • api: public

362 |
364 |
exports.createClient = function(options){
365 |   return new Client(options);
366 | };
367 |
371 |

Command names.

372 | 373 |
  • type: Array

374 |
376 |
exports.commands = [
377 |   // rc
378 |     'getNewBrowserSession'
379 |   , 'setContext'
380 |   , 'testComplete'
381 |   // selenium actions
382 |   , 'addLocationStrategy'
383 |   , 'addScript'
384 |   , 'addSelection'
385 |   , 'allowNativeXpath'
386 |   , 'altKeyDown'
387 |   , 'altKeyUp'
388 |   , 'answerOnNextPrompt'
389 |   , 'assignId'
390 |   , 'break'
391 |   , 'captureEntirePageScreenshot'
392 |   , 'check'
393 |   , 'chooseCancelOnNextConfirmation'
394 |   , 'chooseOkOnNextConfirmation'
395 |   , 'click'
396 |   , 'clickAndWait'
397 |   , 'clickAt'
398 |   , 'clickAtAndWait'
399 |   , 'close'
400 |   , 'contextMenu'
401 |   , 'contextMenuAt'
402 |   , 'controlKeyDown'
403 |   , 'controlKeyUp'
404 |   , 'createCookie'
405 |   , 'deleteAllVisibleCookies'
406 |   , 'deleteCookie'
407 |   , 'deselectPopUp'
408 |   , 'doubleClick'
409 |   , 'doubleClickAt'
410 |   , 'dragAndDrop'
411 |   , 'dragAndDropToObject'
412 |   , 'echo'
413 |   , 'fireEvent'
414 |   , 'focus'
415 |   , 'goBack'
416 |   , 'highlight'
417 |   , 'ignoreAttributesWithoutValue'
418 |   , 'keyDown'
419 |   , 'keyPress'
420 |   , 'keyUp'
421 |   , 'metaKeyDown'
422 |   , 'metaKeyUp'
423 |   , 'mouseDown'
424 |   , 'mouseDownAt'
425 |   , 'mouseDownRight'
426 |   , 'mouseDownRightAt'
427 |   , 'mouseMove'
428 |   , 'mouseMoveAt'
429 |   , 'mouseOut'
430 |   , 'mouseOver'
431 |   , 'mouseUp'
432 |   , 'mouseUpAt'
433 |   , 'mouseUpRight'
434 |   , 'mouseUpRightAt'
435 |   , 'open'
436 |   , 'openWindow'
437 |   , 'refresh'
438 |   , 'removeAllSelections'
439 |   , 'removeScript'
440 |   , 'removeSelection'
441 |   , 'rollup'
442 |   , 'runScript'
443 |   , 'select'
444 |   , 'selectFrame'
445 |   , 'selectPopUp'
446 |   , 'selectWindow'
447 |   , 'setBrowserLogLevel'
448 |   , 'setCursorPosition'
449 |   , 'setMouseSpeed'
450 |   , 'setSpeed'
451 |   , 'setTimeout'
452 |   , 'shiftKeyDown'
453 |   , 'shiftKeyUp'
454 |   , 'submit'
455 |   , 'type'
456 |   , 'typeKeys'
457 |   , 'uncheck'
458 |   , 'useXpathLibrary'
459 |   , 'waitForCondition'
460 |   , 'waitForFrameToLoad'
461 |   , 'waitForPageToLoad'
462 |   , 'waitForPopUp'
463 |   , 'windowFocus'
464 |   , 'windowMaximize'
465 | ];
466 |
470 |

Accessor names.

471 | 472 |
  • type: Array

473 |
475 |
exports.accessors = [
476 |     'ErrorOnNext'
477 |   , 'FailureOnNext'
478 |   , 'Alert'
479 |   , 'AllButtons'
480 |   , 'AllFields'
481 |   , 'AllLinks'
482 |   , 'AllWindowIds'
483 |   , 'AllWindowNames'
484 |   , 'AllWindowTitles'
485 |   , 'Attribute'
486 |   , 'AttributeFromAllWindows'
487 |   , 'BodyText'
488 |   , 'Confirmation'
489 |   , 'Cookie'
490 |   , 'CookieByName'
491 |   , 'CursorPosition'
492 |   , 'ElementHeight'
493 |   , 'ElementIndex'
494 |   , 'ElementPositionLeft'
495 |   , 'ElementPositionTop'
496 |   , 'ElementWidth'
497 |   , 'Eval'
498 |   , 'Expression'
499 |   , 'HtmlSource'
500 |   , 'Location'
501 |   , 'LogMessages'
502 |   , 'MouseSpeed'
503 |   , 'Prompt'
504 |   , 'SelectedId'
505 |   , 'SelectedIds'
506 |   , 'SelectedIndex'
507 |   , 'SelectedIndexes'
508 |   , 'SelectedLabel'
509 |   , 'SelectedLabels'
510 |   , 'SelectedValue'
511 |   , 'SelectedValues'
512 |   , 'SelectOptions'
513 |   , 'Speed'
514 |   , 'Table'
515 |   , 'Text'
516 |   , 'Title'
517 |   , 'Value'
518 |   , 'WhetherThisFrameMatchFrameExpression'
519 |   , 'WhetherThisWindowMatchWindowExpression'
520 |   , 'XpathCount'
521 |   , 'AlertPresent'
522 |   , 'Checked'
523 |   , 'ConfirmationPresent'
524 |   , 'CookiePresent'
525 |   , 'Editable'
526 |   , 'ElementPresent'
527 |   , 'ElementNotPresent'
528 |   , 'Ordered'
529 |   , 'PromptPresent'
530 |   , 'SomethingSelected'
531 |   , 'TextPresent'
532 |   , 'Visible'
533 | ];
534 |
538 |

Generate commands via accessors.

539 | 540 |

All accessors get prefixed with:

541 | 542 |
  • get
  • assert
  • assertNot
  • verify
  • verifyNot
  • waitFor
  • waitForNot
543 | 544 |

For example providing us with:

545 | 546 |
  • getTitle
  • assertTitle
  • verifyTitle
  • ...

547 |
549 |
exports.accessors.map(function(cmd){
550 |   exports.commands.push(
551 |       'get' + cmd
552 |     , 'assert' + cmd
553 |     , 'assertNot' + cmd
554 |     , 'verify' + cmd
555 |     , 'verifyNot' + cmd
556 |     , 'waitFor' + cmd
557 |     , 'waitForNot' + cmd);
558 | });
559 |
563 |

Generate command methods. 564 |

565 |
567 |
exports.commands.map(function(cmd){
568 |   Client.prototype[cmd] = function(){
569 |     // Queue the command invocation
570 |     if (this.queue) {
571 |       var args = Array.prototype.slice.call(arguments);
572 |       return this.enqueue(cmd, args);
573 |     // Direct call
574 |     } else {
575 |       var len = arguments.length
576 |         , fn = arguments[len - 1]
577 |         , args = Array.prototype.slice.call(arguments, 0, len - 1);
578 |       return this.command(cmd, args, fn);
579 |     }
580 |   };
581 | });
582 |

index

lib/soda/index.js
585 |

Export all of ./client. 586 |

587 |
589 |
exports = module.exports = require('./client');
590 |
594 |

Export sauce client. 595 |

596 |
598 |
exports.SauceClient = require('./sauce');
599 | exports.createSauceClient = require('./sauce').createClient;
600 |
604 |

Library version.

605 | 606 |
  • type: String

607 |
609 |
exports.version = '0.2.0';
610 | 
611 |

sauce

lib/soda/sauce.js
614 |

Module dependencies. 615 |

616 |
618 |
var Client = require('./client');
619 |
623 |

Initialize a SauceClient with the given options. A suite of environment 624 | variables are also supported in place of the options described below.

625 | 626 |

Options

627 | 628 |
  • username Saucelabs username
  • access-key Account access key
  • os Operating system ex "Linux"
  • browser Browser name, ex "firefox"
  • browser-version Browser version, ex "3.0.", "7."
  • max-duration Maximum test duration in seconds, ex 300 (5 minutes)
629 | 630 |

Environment Variables

631 | 632 |
  • SAUCE_HOST Defaulting to "saucelabs.com"
  • SAUCE_PORT Defaulting to 4444
  • SAUCE_OS
  • SAUCE_BROWSER
  • SAUCE_USERNAME
  • SAUCE_ACCESS_KEY
  • SAUCE_BROWSER_VERSION
633 | 634 |

635 | 636 |
  • params: Object options

  • api: public

637 |
639 |
var SauceClient = exports = module.exports = function SauceClient(options) {
640 |   options = options || {};
641 |   this.host = process.env.SAUCE_HOST || 'saucelabs.com';
642 |   this.port = process.env.SAUCE_PORT || 4444;
643 |   
644 |   // Check sauce env variables, and provide defaults
645 |   options.os = options.os || process.env.SAUCE_OS || 'Linux';
646 |   options.url = options.url || process.env.SAUCE_BROWSER_URL;
647 |   options.browser = options.browser || process.env.SAUCE_BROWSER || 'firefox';
648 |   options.username = options.username || process.env.SAUCE_USERNAME;
649 |   options['access-key'] = options['access-key'] || process.env.SAUCE_ACCESS_KEY;
650 |   
651 |   // Allow users to specify an empty browser-version
652 |   options['browser-version'] = options['browser-version'] == undefined
653 |     ? (process.env.SAUCE_BROWSER_VERSION || '')
654 |     : (options['browser-version'] || '');
655 | 
656 |   this.url = options.url;
657 |   this.username = options.username;
658 |   this.accessKey = options['access-key'];
659 |   this.options = options;
660 |   this.browser = JSON.stringify(options);
661 | };
662 |
666 |

Interit from Client. 667 |

668 |
670 |
SauceClient.prototype.__proto__ = Client.prototype;
671 |
675 |

Return saucelabs video flv url.

676 | 677 |

678 | 679 |
  • return: String

  • api: public

680 |
682 |
SauceClient.prototype.__defineGetter__('videoUrl', function(){
683 |   return exports.url(this.username, this.sid, 'video.flv');
684 | });
685 |
689 |

Return saucelabs log file url.

690 | 691 |

692 | 693 |
  • return: String

  • api: public

694 |
696 |
SauceClient.prototype.__defineGetter__('logUrl', function(){
697 |   return exports.url(this.username, this.sid, 'selenium-server.log');
698 | });
699 |
703 |

Return saucelabs video embed script.

704 | 705 |

706 | 707 |
  • return: String

  • api: public

708 |
710 |
SauceClient.prototype.__defineGetter__('video', function(){
711 |   return exports.video(this.username, this.accessKey, this.sid);
712 | });
713 |
717 |

Shortcut for new soda.SauceClient().

718 | 719 |

720 | 721 |
  • param: Object options

  • return: Client

  • api: public

722 |
724 |
exports.createClient = function(options){
725 |   return new SauceClient(options);
726 | };
727 |
731 |

Return saucelabs url to jobId's filename.

732 | 733 |

734 | 735 |
  • param: String username

  • param: String jobId

  • param: String filename

  • return: String

  • api: public

736 |
738 |
exports.url = function(username, jobId, filename){
739 |   return 'https://saucelabs.com/rest/'
740 |     + username + '/jobs/'
741 |     + jobId + '/results/'
742 |     + filename;
743 | };
744 |
748 |

Return saucelabs video embed script.

749 | 750 |

751 | 752 |
  • param: String username

  • param: String accessKey

  • param: String jobId

  • return: String

  • api: public

753 |
755 |
exports.video = function(username, accessKey, jobId){
756 |   return '<script src="http://saucelabs.com/video-embed/'
757 |     + jobId + '.js?username='
758 |     + username + '&access_key='
759 |     + accessKey + '"/>';
760 | };
761 |
-------------------------------------------------------------------------------- /examples/google.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | var soda = require('../') 6 | , assert = require('assert'); 7 | 8 | var browser = soda.createClient({ 9 | host: 'localhost' 10 | , port: 4444 11 | , url: 'http://www.google.com' 12 | , browser: 'firefox' 13 | }); 14 | 15 | browser.on('command', function(cmd, args){ 16 | console.log(' \x1b[33m%s\x1b[0m: %s', cmd, args.join(', ')); 17 | }); 18 | 19 | browser 20 | .chain 21 | .session() 22 | .open('/') 23 | .type('q', 'Hello World') 24 | .click('btnG') 25 | .waitForTextPresent('Hello World') 26 | .getTitle(function(title){ 27 | assert.ok(~title.indexOf('hello world'), 'Title did not include the query: ' + title); 28 | }) 29 | .click('link=Advanced search') 30 | .waitForPageToLoad(2000) 31 | .assertAttribute('as_q@value', 'Hello World') 32 | .end(function(err){ 33 | browser.testComplete(function(){ 34 | console.log('done'); 35 | if (err) throw err; 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /examples/google.nested.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var soda = require('../'); 7 | 8 | var browser = soda.createClient({ 9 | host: 'localhost' 10 | , port: 4444 11 | , url: 'http://www.google.com' 12 | , browser: 'firefox' 13 | }); 14 | 15 | browser.session(function(err){ 16 | browser.open('/', function(err, body){ 17 | browser.type('q', 'Hello World', function(err, body){ 18 | browser.click('btnG', function(err, body){ 19 | browser.waitForTextPresent('Hello World', function(err, body){ 20 | browser.assertTitle('hello world - Google Search', function(err, body){ 21 | if (err) throw err; 22 | browser.testComplete(function(err, body){ 23 | console.log('done'); 24 | }); 25 | }); 26 | }); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /examples/learnboost.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var soda = require('../'); 7 | 8 | var browser = soda.createClient({ 9 | url: 'http://sirrobertborden.ca.app.learnboost.com/' 10 | }); 11 | 12 | browser.on('command', function(cmd, args){ 13 | console.log(' \x1b[33m%s\x1b[0m: %s', cmd, args.join(', ')); 14 | }); 15 | 16 | browser 17 | .chain 18 | .session() 19 | .setTimeout(8000) 20 | .open('/') 21 | .waitForPageToLoad(5000) 22 | .clickAndWait('//input[@value="Submit"]') 23 | .clickAndWait('link=Settings') 24 | .type('user[name][first]', 'TJ') 25 | .clickAndWait('//input[@value="Save"]') 26 | .assertTextPresent('Account info updated') 27 | .clickAndWait('link=Log out') 28 | .end(function(err){ 29 | browser.testComplete(function(){ 30 | console.log('done'); 31 | if (err) throw err; 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('./lib/soda'); -------------------------------------------------------------------------------- /lib/soda/client.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Soda - Client 3 | * Copyright(c) 2010 LearnBoost 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var http = require('http') 12 | , qs = require('querystring') 13 | , EventEmitter = require('events').EventEmitter; 14 | 15 | /** 16 | * Initialize a `Client` with the given `options`. 17 | * 18 | * Options: 19 | * 20 | * - `host` Hostname defaulting to localhost 21 | * - `port` Port number defaulting to 4444 22 | * - `browser` Browser name 23 | * - `url` URL string 24 | * 25 | * @params {Object} options 26 | * @api public 27 | */ 28 | 29 | var Client = exports = module.exports = function Client(options) { 30 | this.host = options.host || 'localhost'; 31 | this.port = options.port || 4444; 32 | this.browser = options.browser || 'firefox'; 33 | this.url = options.url; 34 | 35 | // Allow optional "*" prefix 36 | if (this.browser[0] !== '*') { 37 | this.browser = '*' + this.browser; 38 | } 39 | 40 | EventEmitter.call(this); 41 | }; 42 | 43 | /** 44 | * Interit from `EventEmitter`. 45 | */ 46 | 47 | Client.prototype.__proto__ = EventEmitter.prototype; 48 | 49 | /** 50 | * Initialize a new session, then callback `fn(err, sid)` 51 | * 52 | * @param {Function} fn 53 | * @return {Client} 54 | * @api public 55 | */ 56 | 57 | Client.prototype.session = function(fn){ 58 | var self = this; 59 | if (!this.browser) throw new Error('browser required'); 60 | if (!this.url) throw new Error('browser url required'); 61 | if (this.queue) { 62 | return this.enqueue('getNewBrowserSession', [this.browser, this.url], function(body){ 63 | self.sid = body; 64 | }); 65 | } else { 66 | this.command('getNewBrowserSession', [this.browser, this.url], function(err, body){ 67 | if (err) return fn(err); 68 | fn(null, self.sid = body); 69 | }); 70 | } 71 | }; 72 | 73 | /** 74 | * Execute the given `cmd` / `args`, then callback `fn(err, body, res)`. 75 | * 76 | * @param {String} cmd 77 | * @param {Array} args 78 | * @param {Function} fn 79 | * @return {Client} for chaining 80 | * @api private 81 | */ 82 | 83 | Client.prototype.command = function(cmd, args, fn){ 84 | this.emit('command', cmd, args); 85 | 86 | // Path construction 87 | var path = this.commandPath(cmd, args); 88 | 89 | // Assemble Request Options as far as possible 90 | var reqOptions = { 91 | host: this.host, 92 | port: this.port, 93 | path: path, 94 | method: 'GET', 95 | headers: { Host: this.host + (this.port ? ':' + this.port : '') } 96 | } 97 | 98 | var req; 99 | 100 | // Selenium RC can support POST request: http://svn.openqa.org/fisheye/changelog/selenium-rc/?cs=1898, 101 | // we need to switch to use POST if the URL's is too long (Below I use the Internet Explorer's limit). 102 | // See also: http://jira.openqa.org/browse/SRC-50 103 | if (path.length > 2048 && (this.host + path ).length > 2083) { 104 | var postData = this.commandPath(cmd, args).replace('/selenium-server/driver/?', ""); 105 | 106 | reqOptions.path = path; 107 | reqOptions.method = 'POST'; 108 | reqOptions.headers['Content-Length'] = postData.length; 109 | reqOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded'; 110 | } 111 | 112 | req = http.request(reqOptions, function(res) { 113 | res.body = ''; 114 | res.setEncoding('utf8'); 115 | res.on('data', function(chunk){ res.body += chunk; }); 116 | res.on('end', function(){ 117 | if (res.body.indexOf('ERROR') === 0 || 118 | res.body.indexOf('Timed out after ') === 0) { 119 | var err = res.body.replace(/^ERROR: */, ''); 120 | err = cmd + '(' + args.join(', ') + '): ' + err; 121 | fn(new Error(err), res.body, res); 122 | } else { 123 | if (res.body.indexOf('OK') === 0) { 124 | res.body = res.body.replace(/^OK,?/, ''); 125 | } 126 | fn(null, res.body, res); 127 | } 128 | }); 129 | return this; 130 | }); 131 | 132 | if(postData) { 133 | req.write(postData); 134 | } 135 | 136 | req.end(); 137 | }; 138 | 139 | /** 140 | * Construct a `cmd` path with the given `args`. 141 | * 142 | * @param {String} name 143 | * @param {Array} args 144 | * @return {String} 145 | * @api private 146 | */ 147 | 148 | Client.prototype.commandPath = function(cmd, args){ 149 | var obj = { cmd: cmd }; 150 | 151 | // Arguments by nth 152 | if (args) { 153 | args.forEach(function(arg, i){ 154 | obj[i+1] = arg; 155 | }); 156 | } 157 | // Ignore session id for getNewBrowserSession 158 | if (this.sid && cmd !== 'getNewBrowserSession') { 159 | obj.sessionId = this.sid; 160 | } 161 | 162 | return '/selenium-server/driver/?' + qs.stringify(obj); 163 | }; 164 | 165 | /** 166 | * Indicate that commands should be queued. 167 | * 168 | * Example: 169 | * 170 | * browser 171 | * .chain 172 | * .session() 173 | * .open('/') 174 | * .type('q', 'Hello World') 175 | * .clickAndWait('btnG') 176 | * .assertTitle('Hello World - Google') 177 | * .testComplete() 178 | * .end(function(err){ ... }); 179 | * 180 | * @api public 181 | */ 182 | 183 | Client.prototype.__defineGetter__('chain', function(){ 184 | this.queue = []; 185 | return this; 186 | }); 187 | 188 | /** 189 | * Callback `fn(err)` when the queue is complete, or 190 | * when an exception has occurred. 191 | * 192 | * @param {Function} fn 193 | * @api public 194 | */ 195 | 196 | Client.prototype.end = function(fn){ 197 | this._done = function(){this.queue = null; return fn.apply(this, arguments)}; 198 | this.queue.shift()(); 199 | }; 200 | 201 | /** 202 | * Enqueue the given `cmd` and array of `args` for execution. 203 | * 204 | * @param {String} cmd 205 | * @param {Array} args 206 | * @return {Client} 207 | * @api private 208 | */ 209 | 210 | Client.prototype.enqueue = function(cmd, args, fn){ 211 | var self = this 212 | , len = args.length; 213 | 214 | // Indirect callback support 215 | if (typeof args[len - 1] === 'function') { 216 | fn = args.pop(); 217 | } 218 | 219 | this.queue.push(function(){ 220 | self.command(cmd, args, function(err, body, res){ 221 | // Callback support 222 | if (!err && fn) { 223 | try { 224 | fn(body, res); 225 | } catch (err) { 226 | return self._done(err, body, res); 227 | } 228 | } 229 | 230 | if (err) { 231 | self._done(err, body, res); 232 | } else if (self.queue.length) { 233 | self.queue.shift()(); 234 | } else { 235 | self._done(null, body, res); 236 | } 237 | }); 238 | }); 239 | return this; 240 | }; 241 | 242 | /** 243 | * Arbitrary callback `fn(this)` when using the chaining api. 244 | * 245 | * @param {Function} fn 246 | * @return {Client} 247 | * @api public 248 | */ 249 | 250 | Client.prototype.and = function(fn){ 251 | fn.call(this, this); 252 | return this; 253 | }; 254 | 255 | /** 256 | * Shortcut for `new soda.Client()`. 257 | * 258 | * @param {Object} options 259 | * @return {Client} 260 | * @api public 261 | */ 262 | 263 | exports.createClient = function(options){ 264 | return new Client(options); 265 | }; 266 | 267 | /** 268 | * Command names. 269 | * 270 | * @type Array 271 | */ 272 | 273 | exports.commands = [ 274 | // rc 275 | 'getNewBrowserSession' 276 | , 'setContext' 277 | , 'testComplete' 278 | // selenium actions 279 | , 'addLocationStrategy' 280 | , 'addScript' 281 | , 'addSelection' 282 | , 'allowNativeXpath' 283 | , 'altKeyDown' 284 | , 'altKeyUp' 285 | , 'answerOnNextPrompt' 286 | , 'assignId' 287 | , 'break' 288 | , 'captureEntirePageScreenshot' 289 | , 'captureNetworkTraffic' 290 | , 'check' 291 | , 'chooseCancelOnNextConfirmation' 292 | , 'chooseOkOnNextConfirmation' 293 | , 'click' 294 | , 'clickAndWait' 295 | , 'clickAt' 296 | , 'clickAtAndWait' 297 | , 'close' 298 | , 'contextMenu' 299 | , 'contextMenuAt' 300 | , 'controlKeyDown' 301 | , 'controlKeyUp' 302 | , 'createCookie' 303 | , 'deleteAllVisibleCookies' 304 | , 'deleteCookie' 305 | , 'deselectPopUp' 306 | , 'doubleClick' 307 | , 'doubleClickAt' 308 | , 'dragAndDrop' 309 | , 'dragAndDropToObject' 310 | , 'echo' 311 | , 'fireEvent' 312 | , 'focus' 313 | , 'goBack' 314 | , 'highlight' 315 | , 'ignoreAttributesWithoutValue' 316 | , 'keyDown' 317 | , 'keyPress' 318 | , 'keyUp' 319 | , 'metaKeyDown' 320 | , 'metaKeyUp' 321 | , 'mouseDown' 322 | , 'mouseDownAt' 323 | , 'mouseDownRight' 324 | , 'mouseDownRightAt' 325 | , 'mouseMove' 326 | , 'mouseMoveAt' 327 | , 'mouseOut' 328 | , 'mouseOver' 329 | , 'mouseUp' 330 | , 'mouseUpAt' 331 | , 'mouseUpRight' 332 | , 'mouseUpRightAt' 333 | , 'open' 334 | , 'openWindow' 335 | , 'refresh' 336 | , 'removeAllSelections' 337 | , 'removeScript' 338 | , 'removeSelection' 339 | , 'rollup' 340 | , 'runScript' 341 | , 'select' 342 | , 'selectAndWait' 343 | , 'selectFrame' 344 | , 'selectPopUp' 345 | , 'selectWindow' 346 | , 'setBrowserLogLevel' 347 | , 'setCursorPosition' 348 | , 'setMouseSpeed' 349 | , 'setSpeed' 350 | , 'setTimeout' 351 | , 'shiftKeyDown' 352 | , 'shiftKeyUp' 353 | , 'submit' 354 | , 'type' 355 | , 'typeKeys' 356 | , 'uncheck' 357 | , 'useXpathLibrary' 358 | , 'waitForCondition' 359 | , 'waitForFrameToLoad' 360 | , 'waitForPageToLoad' 361 | , 'waitForPopUp' 362 | , 'windowFocus' 363 | , 'windowMaximize' 364 | , 'captureScreenshotToString' 365 | ]; 366 | 367 | /** 368 | * Accessor names. 369 | * 370 | * @type Array 371 | */ 372 | 373 | exports.accessors = [ 374 | 'ErrorOnNext' 375 | , 'FailureOnNext' 376 | , 'Alert' 377 | , 'AllButtons' 378 | , 'AllFields' 379 | , 'AllLinks' 380 | , 'AllWindowIds' 381 | , 'AllWindowNames' 382 | , 'AllWindowTitles' 383 | , 'Attribute' 384 | , 'AttributeFromAllWindows' 385 | , 'BodyText' 386 | , 'Confirmation' 387 | , 'Cookie' 388 | , 'CookieByName' 389 | , 'CursorPosition' 390 | , 'ElementHeight' 391 | , 'ElementIndex' 392 | , 'ElementPositionLeft' 393 | , 'ElementPositionTop' 394 | , 'ElementWidth' 395 | , 'Eval' 396 | , 'Expression' 397 | , 'HtmlSource' 398 | , 'Location' 399 | , 'LogMessages' 400 | , 'MouseSpeed' 401 | , 'Prompt' 402 | , 'SelectedId' 403 | , 'SelectedIds' 404 | , 'SelectedIndex' 405 | , 'SelectedIndexes' 406 | , 'SelectedLabel' 407 | , 'SelectedLabels' 408 | , 'SelectedValue' 409 | , 'SelectedValues' 410 | , 'SelectOptions' 411 | , 'Speed' 412 | , 'Table' 413 | , 'Text' 414 | , 'Title' 415 | , 'Value' 416 | , 'WhetherThisFrameMatchFrameExpression' 417 | , 'WhetherThisWindowMatchWindowExpression' 418 | , 'XpathCount' 419 | , 'AlertPresent' 420 | , 'Checked' 421 | , 'ConfirmationPresent' 422 | , 'CookiePresent' 423 | , 'Editable' 424 | , 'ElementPresent' 425 | , 'ElementNotPresent' 426 | , 'Ordered' 427 | , 'PromptPresent' 428 | , 'SomethingSelected' 429 | , 'TextPresent' 430 | , 'TextNotPresent' 431 | , 'Visible' 432 | ]; 433 | 434 | /** 435 | * Generate commands via accessors. 436 | * 437 | * All accessors get prefixed with: 438 | * 439 | * - get 440 | * - assert 441 | * - assertNot 442 | * - verify 443 | * - verifyNot 444 | * - waitFor 445 | * - waitForNot 446 | * 447 | * For example providing us with: 448 | * 449 | * - getTitle 450 | * - assertTitle 451 | * - verifyTitle 452 | * - ... 453 | * 454 | */ 455 | 456 | exports.accessors.map(function(cmd){ 457 | exports.commands.push( 458 | 'get' + cmd 459 | , 'assert' + cmd 460 | , 'assertNot' + cmd 461 | , 'store' + cmd 462 | , 'verify' + cmd 463 | , 'verifyNot' + cmd 464 | , 'waitFor' + cmd 465 | , 'waitForNot' + cmd); 466 | }); 467 | 468 | /** 469 | * Generate command methods. 470 | */ 471 | 472 | exports.commands.map(function(cmd){ 473 | Client.prototype[cmd] = function(){ 474 | // Queue the command invocation 475 | if (this.queue) { 476 | var args = Array.prototype.slice.call(arguments); 477 | return this.enqueue(cmd, args); 478 | // Direct call 479 | } else { 480 | var len = arguments.length 481 | , fn = arguments[len - 1] 482 | , args = Array.prototype.slice.call(arguments, 0, len - 1); 483 | return this.command(cmd, args, fn); 484 | } 485 | }; 486 | }); 487 | -------------------------------------------------------------------------------- /lib/soda/index.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Soda 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Export all of ./client. 10 | */ 11 | 12 | exports = module.exports = require('./client'); 13 | 14 | /** 15 | * Export sauce client. 16 | */ 17 | 18 | exports.SauceClient = require('./sauce'); 19 | exports.createSauceClient = require('./sauce').createClient; 20 | 21 | /** 22 | * Library version. 23 | * 24 | * @type String 25 | */ 26 | 27 | exports.version = '0.2.4'; 28 | -------------------------------------------------------------------------------- /lib/soda/sauce.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Soda - Sauce 3 | * Copyright(c) 2010 LearnBoost 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var Client = require('./client'); 12 | 13 | /** 14 | * Initialize a `SauceClient` with the given `options`. A suite of environment 15 | * variables are also supported in place of the options described below. 16 | * 17 | * Options: 18 | * 19 | * - `username` Sauce Labs username 20 | * - `access-key` Account access key 21 | * - `os` Operating system ex "Linux" 22 | * - `browser` Browser name, ex "firefox" 23 | * - `browser-version` Browser version, ex "3.0.", "7." 24 | * - `max-duration` Maximum test duration in seconds, ex 300 (5 minutes) 25 | * 26 | * Environment Variables: 27 | * 28 | * - `SAUCE_HOST` Defaulting to "ondemand.saucelabs.com" 29 | * - `SAUCE_PORT` Defaulting to 80 30 | * - `SAUCE_OS` 31 | * - `SAUCE_BROWSER` 32 | * - `SAUCE_USERNAME` 33 | * - `SAUCE_ACCESS_KEY` 34 | * - `SAUCE_BROWSER_VERSION` 35 | * 36 | * @params {Object} options 37 | * @api public 38 | */ 39 | 40 | var SauceClient = exports = module.exports = function SauceClient(options) { 41 | options = options || {}; 42 | this.host = process.env.SAUCE_HOST || 'ondemand.saucelabs.com'; 43 | this.port = process.env.SAUCE_PORT || 80; 44 | 45 | // Check sauce env variables, and provide defaults 46 | options.os = options.os || process.env.SAUCE_OS || 'Linux'; 47 | options.url = options.url || process.env.SAUCE_BROWSER_URL; 48 | options.browser = options.browser || process.env.SAUCE_BROWSER || 'firefox'; 49 | options.username = options.username || process.env.SAUCE_USERNAME; 50 | options['access-key'] = options['access-key'] || process.env.SAUCE_ACCESS_KEY; 51 | 52 | // Allow users to specify an empty browser-version 53 | options['browser-version'] = options['browser-version'] == undefined 54 | ? (process.env.SAUCE_BROWSER_VERSION || '') 55 | : (options['browser-version'] || ''); 56 | 57 | this.url = options.url; 58 | this.username = options.username; 59 | this.accessKey = options['access-key']; 60 | this.options = options; 61 | this.browser = JSON.stringify(options); 62 | }; 63 | 64 | /** 65 | * Interit from `Client`. 66 | */ 67 | 68 | SauceClient.prototype.__proto__ = Client.prototype; 69 | 70 | /** 71 | * Return saucelabs job url. 72 | * 73 | * @return {String} 74 | * @api public 75 | */ 76 | 77 | SauceClient.prototype.__defineGetter__('jobUrl', function(){ 78 | return 'https://saucelabs.com/jobs/' + this.sid; 79 | }); 80 | /** 81 | * Return saucelabs video flv url. 82 | * 83 | * @return {String} 84 | * @api public 85 | */ 86 | 87 | SauceClient.prototype.__defineGetter__('videoUrl', function(){ 88 | return exports.url(this.username, this.sid, 'video.flv'); 89 | }); 90 | 91 | /** 92 | * Return saucelabs log file url. 93 | * 94 | * @return {String} 95 | * @api public 96 | */ 97 | 98 | SauceClient.prototype.__defineGetter__('logUrl', function(){ 99 | return exports.url(this.username, this.sid, 'selenium-server.log'); 100 | }); 101 | 102 | /** 103 | * Return saucelabs video embed script. 104 | * 105 | * @return {String} 106 | * @api public 107 | */ 108 | 109 | SauceClient.prototype.__defineGetter__('video', function(){ 110 | return exports.video(this.username, this.accessKey, this.sid); 111 | }); 112 | 113 | /** 114 | * Shortcut for `new soda.SauceClient()`. 115 | * 116 | * @param {Object} options 117 | * @return {Client} 118 | * @api public 119 | */ 120 | 121 | exports.createClient = function(options){ 122 | return new SauceClient(options); 123 | }; 124 | 125 | /** 126 | * Return saucelabs url to `jobId`'s `filename`. 127 | * 128 | * @param {String} username 129 | * @param {String} jobId 130 | * @param {String} filename 131 | * @return {String} 132 | * @api public 133 | */ 134 | 135 | exports.url = function(username, jobId, filename){ 136 | return 'https://saucelabs.com/rest/' 137 | + username + '/jobs/' 138 | + jobId + '/results/' 139 | + filename; 140 | }; 141 | 142 | /** 143 | * Return saucelabs video embed script. 144 | * 145 | * @param {String} username 146 | * @param {String} accessKey 147 | * @param {String} jobId 148 | * @return {String} 149 | * @api public 150 | */ 151 | 152 | exports.video = function(username, accessKey, jobId){ 153 | return '