├── test ├── artifacts │ ├── syntax-error.js │ ├── other-script.js │ ├── test-script.js │ ├── error-script.js │ ├── empty-page.html │ ├── redirect-script.js │ ├── script-page.html │ ├── dynamic-page.html │ ├── inline-script-error.html │ ├── inline-script-syntax-error.html │ ├── dynamic-eval.js │ ├── dynamic-function.js │ ├── dynamic-timeout.js │ └── pool-page.html ├── dom │ ├── navigator.js │ ├── performance.js │ ├── history.js │ ├── storage.js │ ├── console.js │ ├── dynamic.js │ ├── location.js │ └── async.js ├── lib │ └── index.js ├── exec │ └── pending.js ├── exec.js ├── jquery │ ├── cheerio-shim.js │ └── index.js ├── pool.js └── page.js ├── .gitignore ├── .travis.yml ├── lib ├── index.js ├── dom │ ├── navigator.js │ ├── index.js │ ├── history.js │ ├── performance.js │ ├── storage.js │ ├── console.js │ ├── dynamic.js │ ├── async.js │ └── location.js ├── bootstrap │ └── window.js ├── exec │ ├── source-map.js │ ├── pending.js │ └── index.js ├── jquery │ ├── cheerio-shim.js │ ├── detect.js │ ├── index.js │ └── ajax.js ├── pool.js └── page.js ├── .jshintrc ├── Gruntfile.js ├── LICENSE ├── package.json ├── release-notes.md └── README.md /test/artifacts/syntax-error.js: -------------------------------------------------------------------------------- 1 | s{] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | cov.html 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | -------------------------------------------------------------------------------- /test/artifacts/other-script.js: -------------------------------------------------------------------------------- 1 | var externalVar = inlinedVar + 2; 2 | -------------------------------------------------------------------------------- /test/artifacts/test-script.js: -------------------------------------------------------------------------------- 1 | var externalVar = inlinedVar + 1; 2 | -------------------------------------------------------------------------------- /test/artifacts/error-script.js: -------------------------------------------------------------------------------- 1 | throw new Error('error-script expected'); 2 | -------------------------------------------------------------------------------- /test/artifacts/empty-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | foo 4 | 5 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Exec: require('./exec'), 3 | jquery: require('./jquery'), 4 | page: require('./page'), 5 | pool: require('./pool') 6 | }; 7 | -------------------------------------------------------------------------------- /test/artifacts/redirect-script.js: -------------------------------------------------------------------------------- 1 | setImmediate(function() { 2 | throw new Error('shouldnt run either'); 3 | }, 10); 4 | 5 | 6 | this.location = '/foo'; 7 | 8 | throw new Error('should not have run'); 9 | -------------------------------------------------------------------------------- /test/artifacts/script-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/artifacts/dynamic-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/artifacts/inline-script-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/artifacts/inline-script-syntax-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/artifacts/dynamic-eval.js: -------------------------------------------------------------------------------- 1 | var evalThrow; 2 | 3 | try { 4 | var _savedWindow = window; 5 | eval('window.evalString="evaled! " + ((_savedWindow === this) && (this === window) && (this === window.self));window.fs = require("fs");'); 6 | } catch (err) { 7 | evalThrow = err; 8 | } 9 | -------------------------------------------------------------------------------- /lib/dom/navigator.js: -------------------------------------------------------------------------------- 1 | module.exports = function Navigator(window, options) { 2 | window.navigator = { 3 | userAgent: options.userAgent || '' 4 | }; 5 | 6 | return { 7 | dispose: function() { 8 | window = window.navigator = undefined; 9 | } 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /test/artifacts/dynamic-function.js: -------------------------------------------------------------------------------- 1 | var fnThrow; 2 | 3 | try { 4 | var _savedWindow = this; 5 | var fn = new Function('window.fnString="fned! " + ((_savedWindow === this) && (this === window) && (this === window.self));window.fs = require("fs");'); 6 | fn(); 7 | } catch (err) { 8 | fnThrow = err; 9 | } 10 | -------------------------------------------------------------------------------- /test/artifacts/dynamic-timeout.js: -------------------------------------------------------------------------------- 1 | try { 2 | setTimeout('try{' 3 | + 'window.timeoutString="timeouted! " + ((_savedWindow === this) && (this === window) && (this === window.self));' 4 | + 'window.fs = require("fs");' 5 | + '} catch (err) { window.timeoutThrow = err; }', 0); 6 | } catch (err) { 7 | window.timeoutInitThrow = err; 8 | } 9 | -------------------------------------------------------------------------------- /lib/dom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | async: require('./async'), 3 | console: require('./console'), 4 | dynamic: require('./dynamic'), 5 | location: require('./location'), 6 | history: require('./history'), 7 | navigator: require('./navigator'), 8 | performance: require('./performance'), 9 | storage: require('./storage') 10 | }; 11 | -------------------------------------------------------------------------------- /test/dom/navigator.js: -------------------------------------------------------------------------------- 1 | var navigator = require('../../lib/dom/navigator'); 2 | 3 | describe('dom.navigator', function() { 4 | var window; 5 | beforeEach(function() { 6 | window = {}; 7 | }); 8 | 9 | it('should record user agent', function() { 10 | navigator(window, {userAgent: 'foo'}); 11 | window.navigator.userAgent.should.equal('foo'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/artifacts/pool-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/dom/history.js: -------------------------------------------------------------------------------- 1 | module.exports = function History(window) { 2 | window.history = { 3 | pushState: function(data, title, url) { 4 | window.FruitLoops.redirect(url); 5 | }, 6 | replaceState: function(data, title, url) { 7 | window.FruitLoops.redirect(url); 8 | } 9 | }; 10 | 11 | return { 12 | dispose: function() { 13 | window = window.history = undefined; 14 | } 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/dom/performance.js: -------------------------------------------------------------------------------- 1 | module.exports = function Performance(window) { 2 | window.performance = { 3 | timing: { 4 | navigationStart: Date.now(), 5 | domLoading: Date.now() 6 | } 7 | }; 8 | 9 | return { 10 | reset: function() { 11 | window.performance.timing = { 12 | navigationStart: Date.now(), 13 | domLoading: Date.now() 14 | }; 15 | }, 16 | dispose: function() { 17 | window = window.performance = undefined; 18 | } 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /test/dom/performance.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | 3 | var performance = require('../../lib/dom/performance'); 4 | 5 | describe('dom.performance', function() { 6 | var window; 7 | beforeEach(function() { 8 | window = {}; 9 | 10 | this.stub(Date, 'now', function() { return 42; }); 11 | performance(window); 12 | }); 13 | 14 | it('should record navigationStart', function() { 15 | window.performance.timing.navigationStart.should.equal(42); 16 | }); 17 | it('should record domLoading', function() { 18 | window.performance.timing.domLoading.should.equal(42); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /lib/bootstrap/window.js: -------------------------------------------------------------------------------- 1 | /*global FruitLoops, document */ 2 | var window = this, 3 | self = this; 4 | window.document = { 5 | defaultView: window 6 | }; 7 | 8 | window.$serverSide = true; 9 | window.toString = function() { 10 | // Won't impact Object.prototype.toString but helps us out a bit 11 | return '[object Window]'; 12 | }; 13 | 14 | // FruitLoops object proxies 15 | // WARN: These are deprecated and will likely be removed before the 1.0 release. 16 | window.emit = function(after) { 17 | return FruitLoops.emit(after); 18 | }; 19 | window.onEmit = function(callback) { 20 | return FruitLoops.onEmit(callback); 21 | }; 22 | window.loadInContext = function(href, callback) { 23 | return FruitLoops.loadInContext(href, callback); 24 | }; 25 | -------------------------------------------------------------------------------- /lib/exec/source-map.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | sourceMap = require('source-map'); 3 | 4 | module.exports.create = function() { 5 | var cache = {}; 6 | 7 | function loadSourceMap(file) { 8 | try { 9 | var body = fs.readFileSync(file + '.map'); 10 | return new sourceMap.SourceMapConsumer(body.toString()); 11 | } catch (err) { 12 | /* NOP */ 13 | } 14 | } 15 | 16 | return { 17 | map: function(file, line, column) { 18 | if (cache[file] === undefined) { 19 | cache[file] = loadSourceMap(file) || false; 20 | } 21 | if (!cache[file]) { 22 | return {source: file, line: line, column: column}; 23 | } else { 24 | return cache[file].originalPositionFor({line: line, column: column || 1}); 25 | } 26 | } 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /lib/dom/storage.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = function Storage(window, name) { 4 | /*jshint es5:true */ 5 | var cache = {}; 6 | 7 | window[name] = { 8 | getItem: function(value) { 9 | return cache[value]; 10 | }, 11 | setItem: function(value, key) { 12 | cache[value] = key; 13 | }, 14 | removeItem: function(value) { 15 | delete cache[value]; 16 | }, 17 | key: function(index) { 18 | return _.keys(cache)[index]; 19 | }, 20 | 21 | get length() { 22 | return _.keys(cache).length; 23 | }, 24 | clear: function() { 25 | cache = {}; 26 | } 27 | }; 28 | 29 | return { 30 | reset: function() { 31 | window[name].clear(); 32 | }, 33 | dispose: function() { 34 | window = window[name] = undefined; 35 | } 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "browser": false, 5 | "bitwise": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "forin": true, 9 | "immed": false, 10 | "latedef": false, 11 | "newcap": true, 12 | "noarg": true, 13 | "noempty": true, 14 | "nonew": false, 15 | "plusplus": false, 16 | "regexp": false, 17 | "undef": true, 18 | "unused": false, 19 | "strict": false, 20 | "trailing": true, 21 | "maxparams": 6, 22 | "asi": false, 23 | "boss": false, 24 | "expr": true, 25 | "laxbreak": true, 26 | "loopfunc": true, 27 | "shadow": true, 28 | "nonstandard": true, 29 | "onevar": false, 30 | "-W070": true, 31 | 32 | "predef": [ 33 | "setImmediate", 34 | "clearImmediate", 35 | 36 | "describe", 37 | "it", 38 | "before", 39 | "after", 40 | "beforeEach", 41 | "afterEach", 42 | "should" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /test/dom/history.js: -------------------------------------------------------------------------------- 1 | var history = require('../../lib/dom/history'); 2 | 3 | describe('history', function() { 4 | var spy, 5 | window; 6 | beforeEach(function() { 7 | spy = this.spy(); 8 | window = { 9 | FruitLoops: { 10 | redirect: spy 11 | } 12 | }; 13 | }); 14 | it('should extend the window', function() { 15 | history(window); 16 | should.exist(window.history); 17 | }); 18 | 19 | describe('#pushState', function() { 20 | it('should redirect', function() { 21 | history(window); 22 | 23 | window.history.pushState('', '', 'foo'); 24 | spy.calledWith('foo').should.be.true; 25 | }); 26 | }); 27 | describe('#replaceState', function() { 28 | it('should redirect', function() { 29 | history(window); 30 | 31 | window.history.replaceState('', '', 'foo'); 32 | spy.calledWith('foo').should.be.true; 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global grunt */ 2 | module.exports = function(grunt) { 3 | grunt.initConfig({ 4 | jshint: { 5 | options: { 6 | jshintrc: '.jshintrc' 7 | }, 8 | files: [ 9 | 'lib/**/*.js', 10 | 'test/**/*.js,!test/artifacts/*.js' 11 | ] 12 | }, 13 | 14 | mochacov: { 15 | test: { 16 | options: { 17 | reporter: 'spec', 18 | grep: grunt.option('grep') 19 | } 20 | }, 21 | cov: { 22 | options: { 23 | reporter: 'html-cov', 24 | output: 'cov.html' 25 | } 26 | }, 27 | options: { 28 | require: ['./test/lib'], 29 | files: ['test/dom/*.js', 'test/exec/*.js', 'test/jquery/*.js', 'test/*.js'] 30 | } 31 | } 32 | }); 33 | 34 | grunt.loadNpmTasks('grunt-contrib-jshint'); 35 | grunt.loadNpmTasks('grunt-mocha-cov'); 36 | 37 | grunt.registerTask('test', ['jshint', 'mochacov:test']); 38 | grunt.registerTask('cov', ['mochacov:cov']); 39 | }; 40 | -------------------------------------------------------------------------------- /test/dom/storage.js: -------------------------------------------------------------------------------- 1 | var storage = require('../../lib/dom/storage'); 2 | 3 | describe('storage', function() { 4 | it('should extend the window', function() { 5 | var window = {}; 6 | storage(window, 'localStorage'); 7 | should.exist(window.localStorage); 8 | 9 | storage(window, 'sessionStorage'); 10 | should.exist(window.sessionStorage); 11 | }); 12 | 13 | it('should save data', function() { 14 | var window = {}; 15 | storage(window, 'localStorage'); 16 | 17 | should.not.exist(window.localStorage.getItem('foo')); 18 | window.localStorage.length.should.equal(0); 19 | 20 | window.localStorage.setItem('foo', 'bar'); 21 | window.localStorage.getItem('foo').should.equal('bar'); 22 | window.localStorage.key(0).should.equal('foo'); 23 | window.localStorage.length.should.equal(1); 24 | 25 | window.localStorage.removeItem('foo'); 26 | should.not.exist(window.localStorage.getItem('foo')); 27 | window.localStorage.length.should.equal(0); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011-2013 @WalmartLabs 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | */ 22 | -------------------------------------------------------------------------------- /test/lib/index.js: -------------------------------------------------------------------------------- 1 | var Mocha = require('mocha'), 2 | chai = require("chai"), 3 | sinonChai = require("sinon-chai"); 4 | 5 | // If we are run by global mocha, sniff for that case. 6 | process.mainModule.children.forEach(function(child) { 7 | if (/mocha[\/\\]index.js/.test(child.filename)) { 8 | Mocha = child.exports; 9 | } 10 | }); 11 | 12 | global.should = chai.should(); 13 | chai.use(sinonChai); 14 | chai.use(require('chai-properties')); 15 | 16 | var sinon = require('sinon'); 17 | 18 | sinon.config = { 19 | injectIntoThis: true, 20 | injectInto: null, 21 | properties: ['spy', 'stub', 'mock', 'clock', 'sandbox', 'server', 'requests', 'on'], 22 | useFakeTimers: [10], 23 | useFakeServer: true 24 | }; 25 | 26 | var loadFiles = Mocha.prototype.loadFiles; 27 | Mocha.prototype.loadFiles = function() { 28 | this.suite.beforeEach(function() { 29 | var config = sinon.getConfig(sinon.config); 30 | config.injectInto = this; 31 | this.sandbox = sinon.sandbox.create(config); 32 | }); 33 | this.suite.afterEach(function() { 34 | this.clock.tick(1000); 35 | this.sandbox.verifyAndRestore(); 36 | }); 37 | 38 | return loadFiles.apply(this); 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /lib/dom/console.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = function Console(window, exec) { 4 | window.console = { 5 | log: delegate('log'), 6 | info: delegate('info'), 7 | error: delegate('error'), 8 | warn: delegate('warn'), 9 | 10 | time: delegate('time'), 11 | timeEnd: delegate('timeEnd') 12 | }; 13 | 14 | function delegate(name) { 15 | return function() { 16 | var time = process.hrtime(window.FruitLoops.start)[1]/1e6; 17 | time = time.toFixed(6); 18 | 19 | if (typeof arguments[0] === 'string') { 20 | console[name].apply(console, ['%s %s ' + arguments[0], 'id_' + window.FruitLoops.id, time].concat(mapArgs(_.rest(arguments)))); 21 | } else { 22 | console[name].apply(console, ['id_' + window.FruitLoops.id, time].concat(mapArgs(arguments))); 23 | } 24 | }; 25 | } 26 | 27 | function mapArgs(args) { 28 | return _.map(args, function(arg) { 29 | if (arg && arg.split) { 30 | arg = exec.rewriteStack(arg); 31 | } 32 | return arg; 33 | }); 34 | } 35 | 36 | return { 37 | dispose: function() { 38 | window = window.console = undefined; 39 | } 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fruit-loops", 3 | "version": "0.16.1", 4 | "description": "", 5 | "keywords": [], 6 | "authors": [ 7 | "Kevin Decker (http://incaseofstairs.com)" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/walmartlabs/fruit-loops.git" 12 | }, 13 | "engines": { 14 | "node": ">=0.8" 15 | }, 16 | "main": "./lib", 17 | "dependencies": { 18 | "async": "~0.2.9", 19 | "cheerio": "git+https://github.com/kpdecker/cheerio.git", 20 | "contextify": "~0.1.6", 21 | "lodash": "~4.17.5", 22 | "nipple": "~2.2.0", 23 | "printf": "~0.1.1", 24 | "request": "~2.14.0", 25 | "source-map": "~0.1.8" 26 | }, 27 | "devDependencies": { 28 | "catbox": "4.x.x", 29 | "catbox-memory": "~1.0.1", 30 | "chai": "~1.9.0", 31 | "chai-properties": "^1.1.0", 32 | "grunt": "~0.4.1", 33 | "grunt-cli": "~0.1.8", 34 | "grunt-contrib-jshint": "~0.4.3", 35 | "grunt-mocha-cov": "~0.2.1", 36 | "hapi": "7.x.x", 37 | "mocha": "~1.9", 38 | "sinon": "~1.6.0", 39 | "sinon-chai": "~2.4.0" 40 | }, 41 | "scripts": { 42 | "blanket": { 43 | "pattern": "//^((?!/node_modules/)(?!/test/).)*$/", 44 | "data-cover-flags": { 45 | "branchTracking": true 46 | } 47 | }, 48 | "test": "./node_modules/.bin/grunt test" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/dom/console.js: -------------------------------------------------------------------------------- 1 | var _console = require('../../lib/dom/console'), 2 | Exec = require('../../lib/exec'); 3 | 4 | describe('dom.console', function() { 5 | var window; 6 | beforeEach(function() { 7 | window = { 8 | FruitLoops: { 9 | id: 42, 10 | start: [0,0] 11 | }, 12 | }; 13 | }); 14 | 15 | ['log', 'info', 'warn', 'error'].forEach(function(name) { 16 | describe('#' + name, function() { 17 | beforeEach(function() { 18 | this.stub(console, name); 19 | this.stub(process, 'hrtime', function(start) { 20 | return [1e6-start[0], 1e6-start[1]]; 21 | }); 22 | }); 23 | 24 | it('should printf', function() { 25 | _console(window, Exec.create()); 26 | window.console[name]('foo', 1, 'bar'); 27 | 28 | console[name].should.have.been.calledOnce; 29 | console[name].should.have.been.calledOn(console); 30 | console[name].should.have.been.calledWith('%s %s foo', 'id_42', '1.000000', 1, 'bar'); 31 | }); 32 | it('should pass all args', function() { 33 | var obj = {}; 34 | 35 | _console(window, Exec.create()); 36 | window.console[name]({}, 1, 'bar'); 37 | 38 | console[name].should.have.been.calledOnce; 39 | console[name].should.have.been.calledOn(console); 40 | console[name].should.have.been.calledWith('id_42', '1.000000', obj, 1, 'bar'); 41 | }); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /lib/dom/dynamic.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = function Dynamic(window, options) { 4 | /*jshint evil:true,-W024,-W067 */ 5 | 6 | // Target the global objects 7 | window = window.window; 8 | 9 | if (!options.evil) { 10 | window.eval = function() { 11 | throw new Error('SecurityError: dynamic code must be enabled with evil flag'); 12 | }; 13 | 14 | var $Function = window.Function; 15 | window.Function = function() { 16 | throw new Error('SecurityError: dynamic code must be enabled with evil flag'); 17 | }; 18 | window.Function.prototype = $Function.prototype; 19 | } 20 | 21 | var $setTimeout = window.setTimeout; 22 | window.setTimeout = function(callback, timeout/*, [args...]*/ ) { 23 | var args = arguments; 24 | 25 | if (typeof callback === 'string') { 26 | if (!options.evil) { 27 | throw new Error('SecurityError: dynamic code must be enabled with evil flag'); 28 | } else { 29 | // Implement our own callback method to handle eval of string parameter input 30 | // as node does not natively support this. 31 | args = _.toArray(arguments); 32 | args[0] = function() { 33 | // Force global exec 34 | // http://perfectionkills.com/global-eval-what-are-the-options 35 | (1,window.eval)(callback); 36 | }; 37 | } 38 | } 39 | 40 | return $setTimeout.apply(this, args); 41 | }; 42 | 43 | 44 | return { 45 | dispose: function() { 46 | window = 47 | window.setTimeout = $setTimeout = 48 | window.Function = window.Function.prototype = $Function = 49 | window.eval = undefined; 50 | } 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /lib/dom/async.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = function Async(window, exec) { 4 | var pending = exec.pending, 5 | idCounter = 0; 6 | window.nextTick = function(callback) { 7 | var wrapped = pending.wrap('nextTick', idCounter++, function() { 8 | exec.exec(callback); 9 | }, true); 10 | process.nextTick(wrapped); 11 | }; 12 | 13 | window.setTimeout = function(callback /*, timeout, [args...]*/ ) { 14 | // Remap the callback with our own callback to ensure proper event loop tracking 15 | var args = _.toArray(arguments), 16 | duration = args[1]; 17 | args[0] = function() { 18 | var args = arguments; 19 | exec.exec(function() { 20 | callback.apply(this, args); 21 | }); 22 | 23 | pending.pop('timeout', id, { 24 | requested: duration 25 | }); 26 | }; 27 | 28 | var timeout = setTimeout.apply(this, args), 29 | id = idCounter++; 30 | 31 | pending.push('timeout', id, function() { 32 | clearTimeout(timeout); 33 | }); 34 | return id; 35 | }; 36 | window.clearTimeout = function(timeout) { 37 | pending.cancel('timeout', timeout); 38 | }; 39 | 40 | window.setImmediate = function(callback) { 41 | var timeout = setImmediate(function() { 42 | exec.exec(callback); 43 | pending.pop('immediate', id); 44 | }); 45 | var id = idCounter++; 46 | 47 | pending.push('immediate', id, function() { 48 | clearImmediate(timeout); 49 | }, true); 50 | return id; 51 | }; 52 | window.clearImmediate = function(id) { 53 | pending.cancel('immediate', id); 54 | }; 55 | 56 | return { 57 | dispose: function() { 58 | window = window.nextTick = 59 | window.setTimeout = window.clearTimeout = 60 | window.setImmediate = window.clearImmediate = undefined; 61 | } 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /lib/dom/location.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | module.exports = function Location(window, href) { 4 | var location = new url.Url(); 5 | 6 | location.assign = function(url) { 7 | window.FruitLoops.redirect(url); 8 | }; 9 | 10 | location.toString = function() { 11 | return url.format(location); 12 | }; 13 | 14 | function reset(href) { 15 | location.parse(href, true); 16 | location.host = location.hostname + (location.port ? ':' + location.port : ''); 17 | location.path = location.pathname; 18 | location.origin = location.protocol + '//' + location.host; 19 | location.search = location.search || ''; 20 | location.hash = ''; 21 | } 22 | 23 | reset(href); 24 | 25 | Object.defineProperty(window.document, 'location', { 26 | enumerable: true, 27 | configurable: false, 28 | get: function() { 29 | return location; 30 | }, 31 | set: function(value) { 32 | window.FruitLoops.redirect(value); 33 | } 34 | }); 35 | 36 | return { 37 | // This allows us to define the location property only once, avoiding the memory leak 38 | // outlined here: https://github.com/joyent/node/issues/7454 39 | reset: function(href) { 40 | reset(href); 41 | }, 42 | dispose: function() { 43 | // Explicitly clean up these references as the GC either does not or defers collecting 44 | // of all of this and by proxy the page object when the redirect reference remains. 45 | window = location = undefined; 46 | } 47 | }; 48 | }; 49 | 50 | module.exports.preInit = function(baseContext) { 51 | Object.defineProperty(baseContext, 'location', { 52 | enumerable: true, 53 | configurable: false, 54 | 55 | get: function() { 56 | return baseContext.document.location; 57 | }, 58 | set: function(value) { 59 | baseContext.document.location = value; 60 | } 61 | }); 62 | 63 | return { 64 | dispose: function() { 65 | baseContext = undefined; 66 | } 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /test/dom/dynamic.js: -------------------------------------------------------------------------------- 1 | var fruitLoops = require('../../lib'), 2 | exec = require('../../lib/exec'), 3 | fs = require('fs'), 4 | hapi = require('hapi'), 5 | sinon = require('sinon'); 6 | 7 | describe('dynamic code exec', function() { 8 | it('should not allow dynamic code without evil flag', function(done) { 9 | this.clock.restore(); 10 | 11 | var page = fruitLoops.page({ 12 | userAgent: 'anything but android', 13 | url: '/foo', 14 | index: __dirname + '/../artifacts/dynamic-page.html', 15 | loaded: function(page) { 16 | page.emit('events'); 17 | }, 18 | callback: function(err) { 19 | should.not.exist(err); 20 | 21 | should.not.exist(page.window.evalString); 22 | page.window.evalThrow.toString().should.match(/SecurityError: dynamic code must be enabled with evil flag/); 23 | 24 | should.not.exist(page.window.fnString); 25 | page.window.fnThrow.toString().should.match(/SecurityError: dynamic code must be enabled with evil flag/); 26 | 27 | should.not.exist(page.window.timeoutString); 28 | page.window.timeoutInitThrow.toString().should.match(/SecurityError: dynamic code must be enabled with evil flag/); 29 | 30 | should.not.exist(page.window.fs); 31 | done(); 32 | } 33 | }); 34 | }); 35 | it('should scope all dynamic code to the VM', function(done) { 36 | this.clock.restore(); 37 | 38 | var page = fruitLoops.page({ 39 | userAgent: 'anything but android', 40 | url: '/foo', 41 | evil: true, 42 | index: __dirname + '/../artifacts/dynamic-page.html', 43 | loaded: function(page) { 44 | page.emit('events'); 45 | }, 46 | callback: function(err) { 47 | should.not.exist(err); 48 | 49 | page.window.evalString.should.equal('evaled! true'); 50 | page.window.evalThrow.toString().should.match(/ReferenceError: require is not defined/); 51 | 52 | page.window.fnString.should.equal('fned! true'); 53 | page.window.fnThrow.toString().should.match(/ReferenceError: require is not defined/); 54 | 55 | page.window.timeoutString.should.equal('timeouted! true'); 56 | page.window.timeoutThrow.toString().should.match(/ReferenceError: require is not defined/); 57 | 58 | should.not.exist(page.window.fs); 59 | done(); 60 | } 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /lib/jquery/cheerio-shim.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | Cheerio = require('cheerio'); 3 | 4 | Cheerio.prototype.appendTo = function(relative) { 5 | if (!relative.cheerio) { 6 | relative = this._$(relative); 7 | } 8 | relative.append(this); 9 | return this; 10 | }; 11 | Cheerio.prototype.insertAfter = function(relative) { 12 | if (!relative.cheerio) { 13 | relative = this._$(relative); 14 | } 15 | relative.after(this); 16 | return this; 17 | }; 18 | Cheerio.prototype.insertBefore = function(relative) { 19 | if (!relative.cheerio) { 20 | relative = this._$(relative); 21 | } 22 | relative.before(this); 23 | return this; 24 | }; 25 | Cheerio.prototype.prependTo = function(relative) { 26 | if (!relative.cheerio) { 27 | relative = this._$(relative); 28 | } 29 | relative.prepend(this); 30 | return this; 31 | }; 32 | Cheerio.prototype.replaceAll = function(relative) { 33 | if (!relative.cheerio) { 34 | relative = this._$(relative); 35 | } 36 | relative.replaceWith(this); 37 | return this; 38 | }; 39 | 40 | Cheerio.prototype.bind = Cheerio.prototype.unbind = 41 | Cheerio.prototype.on = Cheerio.prototype.off = 42 | Cheerio.prototype.live = Cheerio.prototype.die = 43 | Cheerio.prototype.delegate = Cheerio.prototype.undelegate = 44 | Cheerio.prototype.one = function() { 45 | return this; 46 | }; 47 | 48 | Cheerio.prototype.forEach = function(callback, scope) { 49 | var elements = this; 50 | elements.each(function(index) { 51 | callback.call(scope || elements, this, index); 52 | }); 53 | return this; 54 | }; 55 | 56 | Cheerio.prototype.detach = Cheerio.prototype.remove; 57 | 58 | Cheerio.prototype.toggle = function(toggle) { 59 | if (toggle === undefined) { 60 | toggle = this.css('display') === 'none'; 61 | } 62 | 63 | this[toggle ? 'show' : 'hide'](); 64 | return this; 65 | }; 66 | Cheerio.prototype.show = function() { 67 | this.css('display', ''); 68 | return this; 69 | }; 70 | Cheerio.prototype.hide = function() { 71 | this.css('display', 'none'); 72 | return this; 73 | }; 74 | Cheerio.prototype.focus = function() { 75 | this.attr('autofocus', 'autofocus'); 76 | return this; 77 | }; 78 | Cheerio.prototype.blur = function() { 79 | this.removeAttr('autofocus'); 80 | return this; 81 | }; 82 | 83 | Cheerio.prototype.animate = function(properties) { 84 | this.css(properties); 85 | 86 | var callback = arguments[arguments.length-1]; 87 | if (callback && callback.callback) { 88 | callback = callback.callback; 89 | } 90 | 91 | if (callback.call) { 92 | var el = this; 93 | setImmediate(function() { 94 | callback.call(el); 95 | }); 96 | } 97 | 98 | return this; 99 | }; 100 | 101 | -------------------------------------------------------------------------------- /test/dom/location.js: -------------------------------------------------------------------------------- 1 | var location = require('../../lib/dom/location'); 2 | 3 | describe('dom.location', function() { 4 | var window, 5 | spy; 6 | beforeEach(function() { 7 | spy = this.spy(); 8 | 9 | window = { 10 | FruitLoops: { 11 | redirect: spy 12 | }, 13 | document: {} 14 | }; 15 | }); 16 | 17 | it('should generate location', function() { 18 | location.preInit(window); 19 | location(window, 'http://foo.bar/foo/bar?baz=bat&boz='); 20 | window.location.host.should.equal('foo.bar'); 21 | window.location.path.should.equal('/foo/bar'); 22 | window.location.origin.should.equal('http://foo.bar'); 23 | window.location.search.should.equal('?baz=bat&boz='); 24 | window.location.hash.should.equal(''); 25 | (window.location+'').should.equal('http://foo.bar/foo/bar?baz=bat&boz='); 26 | }); 27 | it('should handle ports', function() { 28 | location.preInit(window); 29 | location(window, 'https://foo.bar:8080/foo/bar?baz=bat&boz='); 30 | window.location.host.should.equal('foo.bar:8080'); 31 | window.location.origin.should.equal('https://foo.bar:8080'); 32 | }); 33 | it('should provide non-null failover', function() { 34 | location.preInit(window); 35 | location(window, 'http://foo.bar'); 36 | window.location.host.should.equal('foo.bar'); 37 | window.location.path.should.equal('/'); 38 | window.location.origin.should.equal('http://foo.bar'); 39 | window.location.search.should.equal(''); 40 | window.location.hash.should.equal(''); 41 | (window.location+'').should.equal('http://foo.bar/'); 42 | }); 43 | 44 | it('should redirect on assign()', function() { 45 | location.preInit(window); 46 | location(window, 'https://foo.bar:8080/foo/bar?baz=bat&boz='); 47 | window.location.assign('/foo'); 48 | 49 | spy.should.have.been.calledWith('/foo'); 50 | }); 51 | 52 | it('should redirect on window field assign', function() { 53 | location.preInit(window); 54 | location(window, 'https://foo.bar:8080/foo/bar?baz=bat&boz='); 55 | window.location = '/foo'; 56 | 57 | spy.should.have.been.calledWith('/foo'); 58 | }); 59 | it('should redirect on document field assign', function() { 60 | location.preInit(window); 61 | location(window, 'https://foo.bar:8080/foo/bar?baz=bat&boz='); 62 | window.location = '/foo'; 63 | 64 | spy.should.have.been.calledWith('/foo'); 65 | }); 66 | it('should reset search and query parts of url', function() { 67 | location.preInit(window); 68 | var loc = location(window, 'http://foo.bar/foo/bar?baz=bat&boz='); 69 | loc.reset('http://foo1.bar1/foo1/bar1'); 70 | window.location.host.should.equal('foo1.bar1'); 71 | window.location.path.should.equal('/foo1/bar1'); 72 | window.location.origin.should.equal('http://foo1.bar1'); 73 | window.location.search.should.equal(''); 74 | window.location.query.should.eql({}); 75 | window.location.hash.should.equal(''); 76 | (window.location+'').should.equal('http://foo1.bar1/foo1/bar1'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /lib/exec/pending.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | Events = require('events'); 3 | 4 | /* 5 | * Implements an internal event-loop approximation. 6 | * 7 | * Allows for a given exec context to track the operations that are outstanding 8 | * and also preempt future execution after end of life events such as emit. 9 | */ 10 | module.exports.create = function() { 11 | var pending = [], 12 | log = [], 13 | maxPending = 0; 14 | 15 | function pop(type, id, log) { 16 | var len = pending.length; 17 | while (len--) { 18 | var check = pending[len]; 19 | if (check.type === type && check.id === id) { 20 | pending.splice(len, 1); 21 | 22 | if (check.log) { 23 | _.extend(check.log, log); 24 | check.log.duration = Date.now() - check.log.start; 25 | } 26 | 27 | return check; 28 | } 29 | } 30 | } 31 | 32 | return _.extend(new Events.EventEmitter(), { 33 | pending: function() { 34 | return pending.length; 35 | }, 36 | log: function() { 37 | return log; 38 | }, 39 | maxPending: function() { 40 | return maxPending; 41 | }, 42 | 43 | reset: function() { 44 | _.each(pending, function(pending) { 45 | pending.cleanup && pending.cleanup(); 46 | }); 47 | pending = []; 48 | log = []; 49 | maxPending = 0; 50 | this.removeAllListeners(); 51 | }, 52 | 53 | push: function(type, id, cleanup, noLog) { 54 | if (!noLog) { 55 | log.push({ 56 | type: type, 57 | id: id, 58 | 59 | start: Date.now() 60 | }); 61 | } 62 | pending.push({ 63 | type: type, 64 | id: id, 65 | cleanup: cleanup, 66 | 67 | log: !noLog && log[log.length - 1] 68 | }); 69 | 70 | maxPending = Math.max(pending.length, maxPending); 71 | }, 72 | pop: function(type, id, log) { 73 | var pending = pop(type, id, log); 74 | if (pending) { 75 | this.emit('pop'); 76 | } 77 | }, 78 | cancel: function(type, id, log) { 79 | var pending = pop(type, id, log); 80 | if (pending) { 81 | pending.log.cancelled = true; 82 | pending.cleanup && pending.cleanup(); 83 | this.emit('pop'); 84 | } 85 | }, 86 | 87 | /* 88 | * Helper method that links the push+pop+cancel methods and ties it to the 89 | * execution of a given callback. Prevents execution of the callback after cancellation 90 | * for async methods that are otherwise not cancellable. 91 | */ 92 | wrap: function(type, id, callback, noLog) { 93 | var pending = this, 94 | cancel = false; 95 | 96 | pending.push(type, id, function() { 97 | cancel = true; 98 | }, noLog); 99 | 100 | return function() { 101 | if (cancel) { 102 | return; 103 | } 104 | 105 | var ret = callback.apply(this, arguments); 106 | 107 | // We want to pop after exec so we maintain the pending count 108 | pending.pop(type, id); 109 | 110 | return ret; 111 | }; 112 | } 113 | }); 114 | }; 115 | -------------------------------------------------------------------------------- /lib/jquery/detect.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/madrobby/zepto/blob/master/src/detect.js 2 | // Zepto.js 3 | // (c) 2010-2014 Thomas Fuchs 4 | // Zepto.js may be freely distributed under the MIT license. 5 | /*jshint asi: true, boss: true, curly: false, eqeqeq: false */ 6 | 7 | module.exports = function($, window){ 8 | var os = $.os = {}, 9 | browser = $.browser = {}, 10 | ua = window.navigator.userAgent || '', 11 | 12 | webkit = ua.match(/Web[kK]it[\/]{0,1}([\d.]+)/), 13 | android = ua.match(/(Android);?[\s\/]+([\d.]+)?/), 14 | osx = !!ua.match(/\(Macintosh\; Intel /), 15 | ipad = ua.match(/(iPad).*OS\s([\d_]+)/), 16 | ipod = ua.match(/(iPod)(.*OS\s([\d_]+))?/), 17 | iphone = !ipad && ua.match(/(iPhone\sOS)\s([\d_]+)/), 18 | webos = ua.match(/(webOS|hpwOS)[\s\/]([\d.]+)/), 19 | touchpad = webos && ua.match(/TouchPad/), 20 | kindle = ua.match(/Kindle\/([\d.]+)/), 21 | silk = ua.match(/Silk\/([\d._]+)/), 22 | blackberry = ua.match(/(BlackBerry).*Version\/([\d.]+)/), 23 | bb10 = ua.match(/(BB10).*Version\/([\d.]+)/), 24 | rimtabletos = ua.match(/(RIM\sTablet\sOS)\s([\d.]+)/), 25 | playbook = ua.match(/PlayBook/), 26 | chrome = ua.match(/Chrome\/([\d.]+)/) || ua.match(/CriOS\/([\d.]+)/), 27 | firefox = ua.match(/Firefox\/([\d.]+)/), 28 | ie = ua.match(/MSIE\s([\d.]+)/) || ua.match(/Trident\/[\d](?=[^\?]+).*rv:([0-9.].)/), 29 | webview = !chrome && ua.match(/(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/), 30 | safari = webview || ua.match(/Version\/([\d.]+)([^S](Safari)|[^M]*(Mobile)[^S]*(Safari))/) 31 | 32 | // Todo: clean this up with a better OS/browser seperation: 33 | // - discern (more) between multiple browsers on android 34 | // - decide if kindle fire in silk mode is android or not 35 | // - Firefox on Android doesn't specify the Android version 36 | // - possibly devide in os, device and browser hashes 37 | 38 | if (browser.webkit = !!webkit) browser.version = webkit[1] 39 | 40 | if (android) os.android = true, os.version = android[2] 41 | if (iphone && !ipod) os.ios = os.iphone = true, os.version = iphone[2].replace(/_/g, '.') 42 | if (ipad) os.ios = os.ipad = true, os.version = ipad[2].replace(/_/g, '.') 43 | if (ipod) os.ios = os.ipod = true, os.version = ipod[3] ? ipod[3].replace(/_/g, '.') : null 44 | if (webos) os.webos = true, os.version = webos[2] 45 | if (touchpad) os.touchpad = true 46 | if (blackberry) os.blackberry = true, os.version = blackberry[2] 47 | if (bb10) os.bb10 = true, os.version = bb10[2] 48 | if (rimtabletos) os.rimtabletos = true, os.version = rimtabletos[2] 49 | if (playbook) browser.playbook = true 50 | if (kindle) os.kindle = true, os.version = kindle[1] 51 | if (silk) browser.silk = true, browser.version = silk[1] 52 | if (!silk && os.android && ua.match(/Kindle Fire/)) browser.silk = true 53 | if (chrome) browser.chrome = true, browser.version = chrome[1] 54 | if (firefox) browser.firefox = true, browser.version = firefox[1] 55 | if (ie) browser.ie = true, browser.version = ie[1] 56 | if (safari && (osx || os.ios)) {browser.safari = true; if (osx) browser.version = safari[1]} 57 | if (webview) browser.webview = true 58 | 59 | os.tablet = !!(ipad || playbook || (android && !ua.match(/Mobile/)) || 60 | (firefox && ua.match(/Tablet/)) || (ie && !ua.match(/Phone/) && ua.match(/Touch/))) 61 | os.phone = !!(!os.tablet && !os.ipod && (android || iphone || webos || blackberry || bb10 || 62 | (chrome && ua.match(/Android/)) || (chrome && ua.match(/CriOS\/([\d.]+)/)) || 63 | (firefox && ua.match(/Mobile/)) || (ie && ua.match(/Touch/)))) 64 | }; 65 | -------------------------------------------------------------------------------- /test/exec/pending.js: -------------------------------------------------------------------------------- 1 | var Pending = require('../../lib/exec/pending'); 2 | 3 | describe('pending exec', function() { 4 | var pending, 5 | popSpy; 6 | beforeEach(function() { 7 | popSpy = this.spy(); 8 | pending = Pending.create(); 9 | pending.on('pop', popSpy); 10 | }); 11 | 12 | it('should init clean', function() { 13 | pending.pending().should.equal(0); 14 | pending.log().should.eql([]); 15 | }); 16 | 17 | describe('#push', function() { 18 | it('should push events', function() { 19 | pending.push('test', 123, function() {}); 20 | pending.pending().should.equal(1); 21 | pending.log().should.eql([ 22 | { 23 | type: 'test', 24 | id: 123, 25 | start: 10 26 | } 27 | ]); 28 | }); 29 | it('should push without log', function() { 30 | pending.push('test', 123, function() {}, true); 31 | pending.pending().should.equal(1); 32 | pending.log().should.eql([]); 33 | }); 34 | }); 35 | describe('#pop', function() { 36 | it('should pop events', function() { 37 | pending.push('test', 123, function() {}); 38 | this.clock.tick(10); 39 | pending.pop('test', 123); 40 | pending.pending().should.equal(0); 41 | pending.log().should.eql([ 42 | { 43 | type: 'test', 44 | id: 123, 45 | start: 10, 46 | duration: 10 47 | } 48 | ]); 49 | popSpy.should.have.been.calledOnce; 50 | }); 51 | it('should pop events with log data', function() { 52 | pending.push('test', 123, function() {}); 53 | pending.pop('test', 123, {foo: 'bar'}); 54 | pending.log().should.eql([ 55 | { 56 | type: 'test', 57 | id: 123, 58 | start: 10, 59 | duration: 0, 60 | foo: 'bar' 61 | } 62 | ]); 63 | }); 64 | it('should not throw on not found', function() { 65 | pending.push('test', 123, function() {}); 66 | 67 | pending.pop('test', 413); 68 | popSpy.should.not.have.been.called; 69 | }); 70 | }); 71 | 72 | describe('#cancel', function() { 73 | it('should cancel events', function() { 74 | var spy = this.spy(); 75 | pending.push('test', 123, spy); 76 | 77 | pending.cancel('test', 123); 78 | pending.log().should.eql([ 79 | { 80 | type: 'test', 81 | id: 123, 82 | start: 10, 83 | duration: 0, 84 | cancelled: true 85 | } 86 | ]); 87 | spy.callCount.should.equal(1); 88 | popSpy.should.have.been.calledOnce; 89 | }); 90 | it('should cancel events with log data', function() { 91 | var spy = this.spy(); 92 | pending.push('test', 123, spy); 93 | 94 | pending.cancel('test', 123, {foo: 'bar'}); 95 | pending.log().should.eql([ 96 | { 97 | type: 'test', 98 | id: 123, 99 | start: 10, 100 | duration: 0, 101 | foo: 'bar', 102 | cancelled: true 103 | } 104 | ]); 105 | spy.callCount.should.equal(1); 106 | popSpy.should.have.been.calledOnce; 107 | }); 108 | it('should not throw on not found', function() { 109 | pending.push('test', 123, function() {}); 110 | 111 | pending.cancel('test', 413); 112 | popSpy.should.not.have.been.called; 113 | }); 114 | }); 115 | 116 | describe('#wrap', function() { 117 | it('should provide exec method', function() { 118 | var spy = this.spy(); 119 | 120 | var wrap = pending.wrap('test', 123, spy); 121 | spy.should.not.have.been.called; 122 | 123 | wrap(); 124 | spy.should.have.been.calledOnce; 125 | }); 126 | it('should prevent exec on cancel', function() { 127 | var spy = this.spy(); 128 | 129 | var wrap = pending.wrap('test', 123, spy); 130 | spy.should.not.have.been.called; 131 | 132 | pending.cancel('test', 123); 133 | 134 | wrap(); 135 | spy.should.not.have.been.called; 136 | }); 137 | }); 138 | 139 | it('should reset pending events', function() { 140 | var spy = this.spy(), 141 | spy2 = this.spy(); 142 | pending.push('test', 123, spy); 143 | pending.push('bar', 'baz', spy2); 144 | 145 | pending.reset(); 146 | spy.callCount.should.equal(1); 147 | spy2.callCount.should.equal(1); 148 | popSpy.should.not.have.been.called; 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /lib/exec/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | fs = require('fs'), 3 | Pending = require('./pending'), 4 | printf = require('printf'), 5 | SourceMap = require('./source-map'); 6 | 7 | // Number of source lines to include in error message context 8 | const CONTEXT = 4; 9 | 10 | var sourceMap = SourceMap.create(); 11 | 12 | module.exports.create = function(_callback) { 13 | return { 14 | debug: false, 15 | 16 | RedirectError: RedirectError, 17 | 18 | pending: Pending.create(), 19 | 20 | exec: function(exec, errorHandler) { 21 | errorHandler = errorHandler || _callback; 22 | 23 | try { 24 | exec(); 25 | } catch (err) { 26 | // Nop redirect events. We use this throw to stop execution of the current event. 27 | // Subsequent events will be culled when the pending list is cleaned. 28 | if (err._redirect) { 29 | // Be a bit paranoid when dealing with error objects that might not have 30 | // their stack read. 31 | err.stack; 32 | return; 33 | } 34 | 35 | try { 36 | errorHandler(this.processError(err)); 37 | } catch (err) { 38 | // Shit really fucked up, abort the whole damn thing 39 | if (_callback !== errorHandler) { 40 | _callback(err); 41 | } else { 42 | throw err; 43 | } 44 | } 45 | } 46 | }, 47 | processError: function(err) { 48 | return processError.call(this, err); 49 | }, 50 | rewriteStack: function(stack) { 51 | return rewriteStack.call(this, stack); 52 | } 53 | }; 54 | }; 55 | module.exports.reset = function() { 56 | sourceMap = SourceMap.create(); 57 | }; 58 | 59 | function RedirectError(url) { 60 | this.message = 'Redirect ' + url; 61 | this.url = url; 62 | this._redirect = true; 63 | 64 | Error.captureStackTrace(this, RedirectError); 65 | } 66 | RedirectError.prototype = new Error(); 67 | RedirectError.prototype.constructor = RedirectError; 68 | RedirectError.prototype.name = 'RedirectError'; 69 | RedirectError.prototype.stack = ''; 70 | 71 | function processError(err) { 72 | if (err.clientProcessed) { 73 | return err; 74 | } 75 | 76 | var localError = new Error(err.message); 77 | localError.isBoom = true; 78 | localError.clientProcessed = true; 79 | 80 | var stack = err.stack, 81 | map = mapReference(stack), 82 | fileLines = ''; 83 | 84 | if (map) { 85 | try { 86 | fileLines = fileContext(map.source, map.line) + '\n'; 87 | } catch (err) { 88 | /* NOP */ 89 | } 90 | 91 | stack = stack.split(/\n/); 92 | var msg = stack.shift() + '\n' + fileLines; 93 | 94 | localError.message = msg; 95 | localError.stack = msg + this.rewriteStack(stack); 96 | } else { 97 | localError = err; 98 | } 99 | return localError; 100 | } 101 | 102 | /* 103 | * Rewrites all matching source references for the given input. 104 | */ 105 | function rewriteStack(stack) { 106 | if (!_.isArray(stack)) { 107 | // NOP short circuit if there are no candidate references 108 | if (stack.indexOf(' at ') < 0) { 109 | return stack; 110 | } 111 | 112 | stack = stack.split(/\n/g); 113 | } 114 | 115 | var msg = '', 116 | seenClient = true; 117 | for (var i = 0; i < stack.length; i++) { 118 | var frame = stack[i]; 119 | if (!this.debug && ( 120 | frame.indexOf('fruit-loops/lib') >= 0 121 | || frame.indexOf('fruit-loops/node_modules') >= 0 122 | 123 | // And strip node core code 124 | || /(?:at |\()[^\/\\]+\.js/.test(frame))) { 125 | // Don't include anything more than the code we called 126 | if (seenClient) { 127 | msg += ' at (native)\n'; 128 | seenClient = false; 129 | } 130 | } else { 131 | seenClient = true; 132 | 133 | var lookup = mapReference(frame); 134 | if (lookup) { 135 | msg += ' at' + (lookup.name ? ' ' + lookup.name : '') + ' (' + lookup.source + (lookup.line ? ':' + lookup.line : '') + (lookup.column ? ':' + lookup.column : '') + ')\n'; 136 | } else { 137 | msg += (frame || '') + (i + 1 < stack.length ? '\n' : ''); 138 | } 139 | } 140 | } 141 | return msg; 142 | } 143 | 144 | /* 145 | * Uses source map to map an execption from source maped output to input. 146 | */ 147 | function mapReference(pathRef) { 148 | try { 149 | var match = /^\s+at (?:(.*?) \((.*?)\)|(.*?))$/m.exec(pathRef), 150 | location = match && (match[2] || match[3]), 151 | components = location.split(/:(?!\/)/g); 152 | 153 | var map = sourceMap.map(components[0], parseInt(components[1], 10), parseInt(components[2], 10)); 154 | map.name = match[1]; 155 | return map; 156 | } catch (err) { 157 | /* NOP */ 158 | } 159 | } 160 | 161 | /* 162 | * Pulls the file content around the point that failed. 163 | */ 164 | function fileContext(file, line) { 165 | // Input is 1 indexed, the array is 0 indexed. 166 | line--; 167 | 168 | var content = fs.readFileSync(file), 169 | lines = content.toString().split(/\n/g), 170 | 171 | start = Math.max(0, line - CONTEXT), 172 | end = Math.min(line + CONTEXT, lines.length), 173 | 174 | msg = ''; 175 | 176 | for (var i = start; i < end; i++) { 177 | msg += printf('\t% 6d:%s %s\n', i+1, i === line ? '>' : ' ', lines[i]); 178 | } 179 | return msg; 180 | } 181 | 182 | -------------------------------------------------------------------------------- /lib/pool.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | async = require('async'), 3 | fs = require('fs'), 4 | page = require('./page'), 5 | Path = require('path'); 6 | 7 | 8 | module.exports = function Pool(options) { 9 | var context = { 10 | options: options, 11 | cache: {queue: [], pages: [], free: []}, 12 | watching: {} 13 | }; 14 | 15 | if (!options || !options.poolSize) { 16 | throw new Error("Must pass in a poolSize value"); 17 | } 18 | 19 | return { 20 | info: function() { 21 | return { 22 | queued: context.cache.queue.length, 23 | pages: context.cache.pages.length, 24 | free: context.cache.free.length 25 | }; 26 | }, 27 | 28 | navigate: function(path, metadata, callback) { 29 | if (!callback) { 30 | callback = metadata; 31 | metadata = undefined; 32 | } 33 | 34 | return getPage(this, context, path, metadata, callback); 35 | }, 36 | dispose: function() { 37 | _.each(context.cache.pages, function(page) { 38 | page.dispose(); 39 | }); 40 | 41 | _.each(context.watching, function(watcher) { 42 | watcher.close(); 43 | }); 44 | 45 | context.cache = {queue: [], pages: [], free: []}; 46 | context.watching = {}; 47 | } 48 | }; 49 | }; 50 | 51 | function getPage(pool, context, path, metadata, callback) { 52 | var cache = context.cache, 53 | options = context.options, 54 | watching = context.watching; 55 | 56 | function finalize(err, data) { 57 | function returnToQueue() { 58 | if (cache.queue.length) { 59 | // If there are pending calls then we continue them. 60 | var queued = cache.queue.shift(); 61 | clearTimeout(queued.timeout); 62 | setImmediate(function() { 63 | getPage.apply(this, queued.args); 64 | }); 65 | } 66 | 67 | if (!err) { 68 | // Return the page to the pool 69 | cache.free.push(page); 70 | } else { 71 | // If we errored assume that we are in some sort of broken state and destroy the page 72 | page.dispose(); 73 | 74 | cache.pages.splice(cache.pages.indexOf(page), 1); 75 | } 76 | } 77 | 78 | // Notify the caller after we've restored the page to the queue 79 | callback.apply(this, arguments); 80 | callback = undefined; 81 | 82 | if (!err && options.cleanup) { 83 | setImmediate(function() { 84 | options.cleanup(page, returnToQueue); 85 | }); 86 | } else { 87 | returnToQueue(); 88 | } 89 | } 90 | 91 | var page, 92 | queueTimeout; 93 | 94 | if (cache.free.length) { 95 | // Execute the instance from an existing 96 | page = cache.free.pop(); 97 | page.navigate(path, metadata, finalize); 98 | 99 | // Exec navigated within a pending block to ensure that events added as part of the pending 100 | // call are run if the pending call also calls emit. 101 | if (options.navigated) { 102 | page.pending.push('navigate', 1, function() {}); 103 | options.navigated(page, true); 104 | page.pending.pop('navigate', 1); 105 | } 106 | } else if (cache.pages.length < options.poolSize) { 107 | options = _.defaults({ 108 | path: path, 109 | metadata: metadata, 110 | callback: finalize 111 | }, options); 112 | 113 | // Spin up a new page instance. 114 | page = createPage(pool, options, watching); 115 | cache.pages.push(page); 116 | } else { 117 | // Allow callers to limit the size of the queue and offer alternative rendering 118 | // paths if it's unlikely that the request will be served in a timely manner. 119 | if (options.maxQueue && (cache.queue.length >= options.maxQueue)) { 120 | setImmediate(function() { 121 | var err = new Error('EQUEUEFULL'); 122 | err.code = 'EQUEUEFULL'; 123 | callback(err); 124 | }); 125 | return; 126 | } 127 | 128 | // We hit our pool limit. Defer execution until we have 129 | // a VM entry available. 130 | var queueInfo = { 131 | args: _.toArray(arguments) 132 | }; 133 | cache.queue.push(queueInfo); 134 | 135 | // Allow callers to limit the total time that is spent waiting in the queue and 136 | // preempt for failover handling. 137 | if (options.queueTimeout) { 138 | queueInfo.timeout = setTimeout(function() { 139 | cache.queue.splice(cache.queue.indexOf(queueInfo), 1); 140 | 141 | var err = new Error('EQUEUETIMEOUT'); 142 | err.code = 'EQUEUETIMEOUT'; 143 | callback(err); 144 | }, options.queueTimeout); 145 | } 146 | 147 | } 148 | 149 | return page; 150 | } 151 | 152 | function createPage(pool, options, watching) { 153 | // Instrument the navigated callback for the initial page load 154 | if (options.navigated) { 155 | var $loaded = options.loaded; 156 | 157 | options.loaded = function(page) { 158 | $loaded && $loaded.apply(this, arguments); 159 | 160 | options.navigated(page, false); 161 | }; 162 | } 163 | 164 | // Track loaded file if in develoopment mode so we can avoid some server restarts 165 | if (!options.cacheResources) { 166 | var $resolver = options.resolver || function(href) { 167 | return Path.resolve(Path.join(Path.dirname(options.index), href)); 168 | }; 169 | 170 | options.resolver = function(href) { 171 | href = $resolver.apply(this, arguments); 172 | 173 | if (!watching[href]) { 174 | // Blow away all page instances if any files change 175 | watching[href] = fs.watch(href, {persistent: false}, function() { 176 | pool.dispose(); 177 | }); 178 | } 179 | 180 | return href; 181 | }; 182 | } 183 | 184 | return page(options); 185 | } 186 | -------------------------------------------------------------------------------- /lib/jquery/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | ajax = require('./ajax'), 3 | Cheerio = require('cheerio'), 4 | detect = require('./detect'), 5 | url = require('url'); 6 | 7 | require('./cheerio-shim'); 8 | 9 | module.exports = exports = function jQuery(window, html, exec, options) { 10 | var root = Cheerio.load(html); 11 | root._$ = $; 12 | html = undefined; 13 | 14 | function query(selector, context) { 15 | /* jshint -W103 */ 16 | var ret = root(selector, context); 17 | ret._$ = $; 18 | 19 | // We need to remap ourselves to the private context and unfortunately 20 | // the return behavior of the Cheerio constructo makes this difficult to 21 | // implement without ramapping after the fact. 22 | ret.__proto__ = $.fn; 23 | return ret; 24 | } 25 | 26 | var htmlEl, 27 | headEl, 28 | bodyEl; 29 | 30 | function $(selector, context) { 31 | if (typeof selector === 'function') { 32 | window.nextTick(selector); 33 | return $; 34 | } 35 | 36 | // Fast track cheerio instances 37 | if (selector && selector.cheerio) { 38 | return selector; 39 | } 40 | 41 | // Special case the document and window instances 42 | if (selector && ( 43 | selector === 'document' 44 | || isDocument(selector) 45 | || isWindow(selector))) { 46 | var $ = query(); 47 | $.ready = function(callback) { 48 | window.nextTick(callback); 49 | }; 50 | return $; 51 | } 52 | if (context && (isDocument(context) || isWindow(context))) { 53 | context = undefined; 54 | } 55 | 56 | // Cache well known instances once they have been requested 57 | if (selector === 'html' || selector === ':root') { 58 | htmlEl = htmlEl || query(selector, context); 59 | return htmlEl; 60 | } else if (selector === 'body') { 61 | bodyEl = bodyEl || query(selector, context); 62 | return bodyEl; 63 | } else if (selector === 'head') { 64 | headEl = headEl || query(selector, context); 65 | return headEl; 66 | } else { 67 | return query(selector, context); 68 | } 69 | } 70 | 71 | $.fn = new Cheerio(); 72 | 73 | // Expose instance fn fields to derived $ instances (such as from $().find) 74 | $.fn._make = function(dom) { 75 | /* jshint -W103 */ 76 | var ret = Cheerio.prototype._make.call(this, dom); 77 | ret.__proto__ = $.fn; 78 | return ret; 79 | }; 80 | 81 | $.each = function(elements, callback) { 82 | _.every(elements, function(el, i) { 83 | return callback.call(el, i, el) !== false; 84 | }); 85 | return elements; 86 | }; 87 | $.extend = function(deep) { 88 | if (deep === true) { 89 | return _.merge.apply(_, _.rest(arguments, 1)); 90 | } else { 91 | return _.extend.apply(_, arguments); 92 | } 93 | }; 94 | $.globalEval = function(script) { 95 | /*jshint evil:true,-W024,-W067 */ 96 | // Force global exec 97 | // http://perfectionkills.com/global-eval-what-are-the-options 98 | return (1,window.eval)(script); 99 | }; 100 | 101 | $.grep = _.filter; 102 | $.inArray = function(value, elements, fromIndex) { 103 | return elements.indexOf(value, fromIndex); 104 | }; 105 | 106 | $.isArray = _.isArray; 107 | $.isFunction = _.isFunction; 108 | $.isNumeric = _.isNumber; 109 | $.isEmptyObject = isEmptyObject; 110 | $.isPlainObject = isPlainObject; 111 | $.isWindow = isWindow; 112 | $.type = type; 113 | 114 | $.makeArray = _.toArray; 115 | $.map = _.map; 116 | $.merge = function(first, second) { 117 | first.splice.apply(first, [first.length, 0].concat(second)); 118 | return first; 119 | }; 120 | $.noop = _.noop; 121 | $.now = function() { 122 | return Date.now(); 123 | }; 124 | $.param = function(params) { 125 | return url.format({query: params}).substring(1); 126 | }; 127 | $.trim = function(str) { 128 | return str.trim(); 129 | }; 130 | $.parseJSON = JSON.parse; 131 | $.proxy = function(obj, name) { 132 | if (typeof name === 'string') { 133 | return _.bindKey.apply(this, arguments); 134 | } else { 135 | return _.bind.apply(this, arguments); 136 | } 137 | }; 138 | 139 | detect($, window); 140 | 141 | window.jQuery = window.Zepto = window.$ = $; 142 | 143 | var ajaxInstance = ajax(window, exec, options && options.ajax); 144 | return { 145 | $: $, 146 | root: root, 147 | ajax: ajaxInstance, 148 | 149 | dispose: function() { 150 | ajaxInstance.dispose(); 151 | 152 | window = window.jQuery = window.Zepto = window.$ = $.fn = ajaxInstance = root._$ = root = undefined; 153 | } 154 | }; 155 | }; 156 | 157 | 158 | var objectMatch = /\[object (.*)\]/; 159 | function type(obj) { 160 | if (obj === null) { 161 | return 'null'; 162 | } 163 | 164 | var type = typeof obj; 165 | if (type === 'object') { 166 | // We can't modify the output of Object toString so first check the instance toString for 167 | // well known objects 168 | // Since $ instances will render their html content on toString, we want to also 169 | // check for a few known fields on the window object. 170 | if (obj.self === obj && obj.document && obj.toString() === '[object Window]') { 171 | type = 'window'; 172 | } else if (objectMatch.test(Object.prototype.toString.call(obj))) { 173 | type = RegExp.$1.toLowerCase(); 174 | } 175 | } 176 | return type; 177 | } 178 | function isDocument(obj) { 179 | return obj.defaultView && isWindow(obj.defaultView) && obj.defaultView.document === obj; 180 | } 181 | function isEmptyObject(obj) { 182 | if (type(obj) !== 'object') { 183 | return false; 184 | } 185 | for (var name in obj) { 186 | if (obj.hasOwnProperty(name)) { 187 | return false; 188 | } 189 | } 190 | return true; 191 | } 192 | function isPlainObject(obj) { 193 | return type(obj) === 'object' 194 | && !isDocument(obj) 195 | && Object.getPrototypeOf(obj) === Object.prototype; 196 | } 197 | function isWindow(obj) { 198 | return type(obj) === 'window'; 199 | } 200 | -------------------------------------------------------------------------------- /test/dom/async.js: -------------------------------------------------------------------------------- 1 | var fruitLoops = require('../../lib'), 2 | Exec = require('../../lib/exec'), 3 | sinon = require('sinon'); 4 | 5 | describe('async', function() { 6 | var spy, 7 | page; 8 | 9 | beforeEach(function() { 10 | spy = this.spy(); 11 | 12 | page = fruitLoops.page({ 13 | userAgent: 'anything but android', 14 | url: { 15 | path: '/foo' 16 | }, 17 | index: __dirname + '/../artifacts/empty-page.html', 18 | callback: spy 19 | }); 20 | }); 21 | 22 | describe('#nextTick', function() { 23 | it('should execute via nextTick', function() { 24 | this.stub(process, 'nextTick', function(callback) { callback(); }); 25 | 26 | var timeoutSpy = this.spy(); 27 | page.window.nextTick(timeoutSpy); 28 | this.clock.tick(100); 29 | 30 | timeoutSpy.should.have.been.calledOnce; 31 | spy.should.not.have.been.called; 32 | }); 33 | it('should handle throws', function() { 34 | this.stub(process, 'nextTick', function(callback) { callback(); }); 35 | 36 | page.window.nextTick(function() { 37 | throw new Error('Expected!'); 38 | }); 39 | this.clock.tick(100); 40 | 41 | spy.firstCall.args[0].should.match(/Expected!/); 42 | }); 43 | 44 | it('should cancel exec', function() { 45 | var callback; 46 | this.stub(process, 'nextTick', function(_callback) { callback = _callback; }); 47 | 48 | var timeoutSpy = this.spy(); 49 | page.window.nextTick(timeoutSpy); 50 | 51 | page.pending.reset(); 52 | callback(); 53 | 54 | timeoutSpy.should.not.have.been.called; 55 | spy.should.not.have.been.called; 56 | }); 57 | 58 | it('should emit after all timeouts are complete', function(done) { 59 | this.clock.restore(); 60 | 61 | var emit = this.spy(), 62 | timeout = this.spy(); 63 | page = fruitLoops.page({ 64 | userAgent: 'anything but android', 65 | url: { 66 | path: '/foo' 67 | }, 68 | index: __dirname + '/../artifacts/empty-page.html', 69 | loaded: function() { 70 | page.window.nextTick(timeout, 10); 71 | page.window.nextTick(timeout, 100); 72 | page.window.emit('events'); 73 | }, 74 | callback: function(err) { 75 | timeout.should.have.been.calledTwice; 76 | done(); 77 | } 78 | }); 79 | }); 80 | }); 81 | 82 | describe('#setTimeout', function() { 83 | it('should execute via setTimeout', function() { 84 | var timeoutSpy = this.spy(); 85 | page.window.setTimeout(timeoutSpy); 86 | this.clock.tick(100); 87 | 88 | timeoutSpy.should.have.been.calledOnce; 89 | spy.should.not.have.been.called; 90 | }); 91 | it('should execute via setTimeout with args', function() { 92 | var timeoutSpy = this.spy(); 93 | page.window.setTimeout(timeoutSpy, 10, 3, 2, 1); 94 | this.clock.tick(100); 95 | 96 | timeoutSpy.should.have.been.calledOnce; 97 | timeoutSpy.should.have.been.calledWith(3, 2, 1); 98 | spy.should.not.have.been.called; 99 | }); 100 | it('should handle throws', function() { 101 | page.window.setTimeout(function() { 102 | throw new Error('Expected!'); 103 | }); 104 | this.clock.tick(100); 105 | 106 | spy.firstCall.args[0].should.match(/Expected!/); 107 | }); 108 | it('should clear on clearTimeout', function() { 109 | var timeoutSpy = this.spy(); 110 | var timeout = page.window.setTimeout(timeoutSpy); 111 | page.window.clearTimeout(timeout); 112 | this.clock.tick(100); 113 | 114 | timeoutSpy.should.not.have.been.called; 115 | spy.should.not.have.been.called; 116 | }); 117 | 118 | it('should emit after all timeouts are complete', function(done) { 119 | this.clock.restore(); 120 | 121 | var emit = this.spy(), 122 | timeout = this.spy(); 123 | page = fruitLoops.page({ 124 | userAgent: 'anything but android', 125 | url: { 126 | path: '/foo' 127 | }, 128 | index: __dirname + '/../artifacts/empty-page.html', 129 | loaded: function() { 130 | page.window.setTimeout(timeout, 10); 131 | page.window.setTimeout(timeout, 100); 132 | page.window.emit('events'); 133 | }, 134 | callback: function(err) { 135 | timeout.should.have.been.calledTwice; 136 | done(); 137 | } 138 | }); 139 | }); 140 | }); 141 | 142 | describe('#setImmediate', function() { 143 | it('should execute via setImmediate', function(done) { 144 | page.window.setImmediate(function() { 145 | setImmediate(function() { 146 | spy.should.not.have.been.called; 147 | 148 | done(); 149 | }); 150 | }); 151 | }); 152 | it('should handle throws', function(done) { 153 | page.window.nextTick(function() { 154 | throw new Error('Expected!'); 155 | }); 156 | 157 | setImmediate(function() { 158 | spy.firstCall.args[0].should.match(/Expected!/); 159 | done(); 160 | }); 161 | }); 162 | it('should clear on clearImmediate', function(done) { 163 | var timeoutSpy = this.spy(); 164 | var timeout = page.window.setImmediate(timeoutSpy); 165 | page.window.clearImmediate(timeout); 166 | 167 | setImmediate(function() { 168 | timeoutSpy.should.not.have.been.called; 169 | spy.should.not.have.been.called; 170 | done(); 171 | }); 172 | }); 173 | 174 | it('should emit after all timeouts are complete', function(done) { 175 | this.clock.restore(); 176 | 177 | var emit = this.spy(), 178 | timeout = this.spy(); 179 | page = fruitLoops.page({ 180 | userAgent: 'anything but android', 181 | url: { 182 | path: '/foo' 183 | }, 184 | index: __dirname + '/../artifacts/empty-page.html', 185 | loaded: function() { 186 | page.window.setImmediate(timeout, 10); 187 | page.window.setImmediate(timeout, 100); 188 | page.window.emit('events'); 189 | }, 190 | callback: function(err) { 191 | timeout.should.have.been.calledTwice; 192 | done(); 193 | } 194 | }); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /test/exec.js: -------------------------------------------------------------------------------- 1 | var Exec = require('../lib/exec'), 2 | fs = require('fs'), 3 | sourceMap = require('source-map'); 4 | 5 | describe('exec', function() { 6 | var exec, 7 | globalHandler; 8 | beforeEach(function() { 9 | var self = this; 10 | globalHandler = this.spy(function(err) { throw err; }); 11 | Exec.reset(); 12 | exec = Exec.create(globalHandler); 13 | exec.debug = false; 14 | 15 | this.stub(fs, 'readFileSync', function(name) { 16 | if (/^new/.test(name)) { 17 | return 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 18 | } else { 19 | return name; 20 | } 21 | }); 22 | this.stub(sourceMap, 'SourceMapConsumer', function(name) { 23 | if (/baz/.test(name)) { 24 | throw new Error(); 25 | } 26 | 27 | this.originalPositionFor = self.spy(function(pos) { 28 | if (/exec.js/.test(name)) { 29 | return {source: 'new' + name, line: 2, column: pos.column+10}; 30 | } else { 31 | return {source: 'new' + name, line: pos.line+10, column: pos.column+10}; 32 | } 33 | }); 34 | }); 35 | }); 36 | 37 | describe('#exec', function() { 38 | it('should include error context', function(done) { 39 | try { 40 | exec.exec(function() { 41 | throw new Error(); 42 | }); 43 | } catch (err) { 44 | err.stack.should.match(/\t 1: Line 1\n\t 2:> Line 2\n\t 3: Line 3\n\t 4: Line 4\n\t 5: Line 5\n\n/); 45 | globalHandler.should.have.been.calledOnce; 46 | done(); 47 | } 48 | }); 49 | 50 | it('should pass to error handler', function(done) { 51 | exec.exec(function() { 52 | throw new Error(); 53 | }, function(err) { 54 | err.stack.should.match(/\t 1: Line 1\n\t 2:> Line 2\n\t 3: Line 3\n\t 4: Line 4\n\t 5: Line 5\n\n/); 55 | globalHandler.should.not.have.been.called; 56 | done(); 57 | }); 58 | }); 59 | 60 | it('should handle error in error handler', function(done) { 61 | try { 62 | exec.exec(function() { 63 | throw new Error(); 64 | }, function(err) { 65 | throw new Error('Foo'); 66 | }); 67 | } catch (err) { 68 | err.should.match(/Foo/); 69 | globalHandler.should.have.been.calledOnce; 70 | done(); 71 | } 72 | }); 73 | 74 | it('should rewriteStack', function() { 75 | try { 76 | exec.exec(function() { 77 | throw new Error(); 78 | }); 79 | } catch (err) { 80 | err.stack.should.match(/at \(new.*?exec.js.map.*?\)\n at \(native\)\n/); 81 | } 82 | }); 83 | it('should rewriteStack once', function() { 84 | try { 85 | exec.exec(function() { 86 | exec.exec(function() { 87 | throw new Error(); 88 | }); 89 | }); 90 | } catch (err) { 91 | err.stack.should.match(/at \(new.*?exec.js.map.*?\)\n at \(native\)\n/); 92 | } 93 | }); 94 | 95 | it('should leave invalid stacks untouched', function() { 96 | sourceMap.SourceMapConsumer.restore(); 97 | try { 98 | exec.exec(function() { 99 | var error = new Error(); 100 | error.stack = 'foo'; 101 | throw error; 102 | }); 103 | } catch (err) { 104 | err.stack.should.equal('foo'); 105 | } 106 | }); 107 | }); 108 | 109 | describe('#RedirectError', function() { 110 | it('should include messages', function() { 111 | var redirect = new exec.RedirectError('foo'); 112 | redirect.message.should.equal('Redirect foo'); 113 | redirect.toString().should.equal('RedirectError: Redirect foo'); 114 | }); 115 | it('should include flags', function() { 116 | var redirect = new exec.RedirectError('foo'); 117 | redirect._redirect.should.equal(true); 118 | redirect.url.should.equal('foo'); 119 | }); 120 | it('should include stack information', function() { 121 | var redirect = new exec.RedirectError('foo'); 122 | redirect.stack.should.match(/\/test\/exec.js/); 123 | }); 124 | }); 125 | 126 | describe('#processError', function() { 127 | it('should remap matched lines', function() { 128 | var err = new Error('Foo'); 129 | err.stack = 'Foo\n' 130 | + ' at functionName (foo/bar:10:20)\n' 131 | + ' at foo/fruit-loops/lib/bar\n' 132 | + ' at foo/fruit-loops/lib/bak\n' 133 | + ' at baz/bat\n'; 134 | 135 | err = exec.processError(err); 136 | err.message.should.equal('Foo\n\n'); 137 | err.clientProcessed.should.be.true; 138 | err.stack.should.equal( 139 | 'Foo\n\n' 140 | + ' at functionName (newfoo/bar.map:20:30)\n' 141 | + ' at (native)\n' 142 | + ' at (baz/bat)\n'); 143 | 144 | fs.readFileSync.should 145 | .have.been.calledWith('foo/bar.map') 146 | .have.been.calledWith('baz/bat.map'); 147 | }); 148 | }); 149 | 150 | describe('#rewriteStack', function() { 151 | it('should remap matched lines', function() { 152 | exec.rewriteStack( 153 | 'Foo\n' 154 | + ' at functionName (foo/bar:10:20)\n' 155 | + ' at foo/fruit-loops/lib/bar\n' 156 | + ' at foo/fruit-loops/lib/bak\n' 157 | + ' at baz/bat\n' 158 | ).should.equal( 159 | 'Foo\n' 160 | + ' at functionName (newfoo/bar.map:20:30)\n' 161 | + ' at (native)\n' 162 | + ' at (baz/bat)\n'); 163 | 164 | fs.readFileSync.should 165 | .have.been.calledWith('foo/bar.map') 166 | .have.been.calledWith('baz/bat.map'); 167 | }); 168 | 169 | it('should handle array input', function() { 170 | exec.rewriteStack([ 171 | 'Foo', 172 | ' at functionName (foo/bar:10:20)', 173 | ' at foo/fruit-loops/lib/bar', 174 | ' at foo/fruit-loops/lib/bak', 175 | ' at baz/bat' 176 | ]).should.equal( 177 | 'Foo\n' 178 | + ' at functionName (newfoo/bar.map:20:30)\n' 179 | + ' at (native)\n' 180 | + ' at (baz/bat)\n'); 181 | 182 | fs.readFileSync.should 183 | .have.been.calledWith('foo/bar.map') 184 | .have.been.calledWith('baz/bat.map'); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/jquery/cheerio-shim.js: -------------------------------------------------------------------------------- 1 | var $ = require('../../lib/jquery'), 2 | Cheerio = require('cheerio'), 3 | dom = require('../../lib/dom'); 4 | 5 | describe('cheerio-shim', function() { 6 | var window, 7 | inst; 8 | beforeEach(function() { 9 | window = { 10 | toString: function() { 11 | return '[object Window]'; 12 | }, 13 | nextTick: function(callback) { 14 | callback(); 15 | } 16 | }; 17 | window.self = window; 18 | window.document = { 19 | defaultView: window 20 | }; 21 | dom.navigator(window, {userAgent: ''}); 22 | inst = $(window, '
'); 23 | }); 24 | 25 | describe('#appendTo', function() { 26 | it('should insert into', function() { 27 | inst.$('red').appendTo(inst.$('body')); 28 | inst.$('body').html().should.equal('
red'); 29 | }); 30 | it('should insert int selector', function() { 31 | inst.$('red').appendTo('body'); 32 | inst.$('body').html().should.equal('
red'); 33 | }); 34 | }); 35 | describe('#insertAfter', function() { 36 | it('should insert after', function() { 37 | // WARN: Hacking around a cheerio bug 38 | // https://github.com/MatthewMueller/cheerio/issues/368 39 | inst = $(window, '
'); 40 | inst.$('red').insertAfter(inst.$('div')); 41 | inst.$('body').html().should.equal('
red'); 42 | }); 43 | it('should insert after selector', function() { 44 | // WARN: Hacking around a cheerio bug 45 | // https://github.com/MatthewMueller/cheerio/issues/368 46 | inst = $(window, '
'); 47 | inst.$('red').insertAfter('div'); 48 | inst.$('body').html().should.equal('
red'); 49 | }); 50 | }); 51 | describe('#insertBefore', function() { 52 | it('should insert before', function() { 53 | // WARN: Hacking around a cheerio bug 54 | // https://github.com/MatthewMueller/cheerio/issues/368 55 | inst = $(window, '
'); 56 | inst.$('red').insertBefore(inst.$('div')); 57 | inst.$('body').html().should.equal('red
'); 58 | }); 59 | it('should insert before selector', function() { 60 | // WARN: Hacking around a cheerio bug 61 | // https://github.com/MatthewMueller/cheerio/issues/368 62 | inst = $(window, '
'); 63 | inst.$('red').insertBefore('div'); 64 | inst.$('body').html().should.equal('red
'); 65 | }); 66 | }); 67 | describe('#prependTo', function() { 68 | it('should insert into', function() { 69 | inst.$('red').prependTo(inst.$('body')); 70 | inst.$('body').html().should.equal('red
'); 71 | }); 72 | it('should insert into selector', function() { 73 | inst.$('red').prependTo('body'); 74 | inst.$('body').html().should.equal('red
'); 75 | }); 76 | }); 77 | describe('#replaceAll', function() { 78 | it('should replace', function() { 79 | // WARN: Hacking around a cheerio bug 80 | // https://github.com/MatthewMueller/cheerio/issues/368 81 | inst = $(window, '
'); 82 | inst.$('red').replaceAll(inst.$('div')); 83 | inst.$('body').html().should.equal('red'); 84 | }); 85 | it('should replace with selector', function() { 86 | // WARN: Hacking around a cheerio bug 87 | // https://github.com/MatthewMueller/cheerio/issues/368 88 | inst = $(window, '
'); 89 | inst.$('red').replaceAll('div'); 90 | inst.$('body').html().should.equal('red'); 91 | }); 92 | }); 93 | 94 | describe('#animate', function() { 95 | it('should set css and callback', function(done) { 96 | var $el = inst.$('body'); 97 | $el.animate({display: 'none', top: '100px'}, function() { 98 | $el.css().should.eql({ 99 | display: 'none', 100 | top: '100px' 101 | }); 102 | done(); 103 | }); 104 | }); 105 | it('should set css and callback options', function(done) { 106 | var $el = inst.$('body'); 107 | $el.animate({display: 'none', top: '100px'}, { 108 | callback: function() { 109 | $el.css().should.eql({ 110 | display: 'none', 111 | top: '100px' 112 | }); 113 | done(); 114 | } 115 | }); 116 | }); 117 | }); 118 | 119 | describe('#get', function() { 120 | it('should deref individual elements', function() { 121 | var els = inst.$('div'); 122 | els.eq(1)[0].should.equal(els[1]); 123 | }); 124 | }); 125 | 126 | describe('#forEach', function() { 127 | it('should iterate', function() { 128 | var spy = this.spy(), 129 | els = inst.$('div'); 130 | 131 | els.forEach(spy); 132 | 133 | spy.should.have.been.calledTwice; 134 | spy.should.have.been.calledOn(els); 135 | }); 136 | }); 137 | 138 | describe('#toggle', function() { 139 | it('should toggle', function() { 140 | var $el = inst.$('body'); 141 | should.not.exist($el.css('display')); 142 | 143 | $el.toggle(); 144 | $el.css('display').should.equal('none'); 145 | 146 | $el.toggle(); 147 | should.not.exist($el.css('display')); 148 | }); 149 | it('should show', function() { 150 | var $el = inst.$('body'); 151 | should.not.exist($el.css('display')); 152 | 153 | $el.toggle(true); 154 | should.not.exist($el.css('display')); 155 | }); 156 | it('should hide', function() { 157 | var $el = inst.$('body'); 158 | should.not.exist($el.css('display')); 159 | 160 | $el.toggle(false); 161 | $el.css('display').should.equal('none'); 162 | }); 163 | }); 164 | 165 | describe('#focus', function() { 166 | it('should apply attr', function() { 167 | var $el = inst.$('body'); 168 | should.not.exist($el.attr('autofocus')); 169 | 170 | $el.focus(); 171 | $el.attr('autofocus').should.equal('autofocus'); 172 | 173 | $el.blur(); 174 | $el.attr('autofocus').should.equal(false); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /lib/jquery/ajax.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | Events = require('events'), 3 | Nipple = require('nipple'), 4 | request = require('request'), 5 | Url = require('url'); 6 | 7 | var Package = require('../../package'); 8 | 9 | var CACHABLE_STATUS = { 10 | 200: true, 11 | 203: true, 12 | 206: true, 13 | 300: true, 14 | 301: true, 15 | 410: true 16 | }; 17 | 18 | function Ajax() { 19 | Events.EventEmitter.call(this); 20 | } 21 | Ajax.prototype = Object.create(Events.EventEmitter.prototype); 22 | 23 | module.exports = exports = function(window, exec, options) { 24 | var ajax = new Ajax(), 25 | 26 | ajaxCache = options && options.cache, 27 | shortCircuit = options && options.shortCircuit, 28 | globalTimeout = options && options.timeout, 29 | 30 | requests, 31 | xhrs, 32 | responses, 33 | minimumCache, 34 | counter = 0; 35 | 36 | function generateFunc(url, next) { 37 | execRequest({url: url}, undefined, function(err, response) { 38 | if (err) { 39 | return next(err); 40 | } 41 | 42 | var ttl = 0; 43 | if (!response.cachingInfo['no-cache'] && !response.cachingInfo.private) { 44 | ttl = response.cachingInfo.expires - Date.now(); 45 | } 46 | 47 | next(undefined, response, ttl); 48 | }); 49 | } 50 | 51 | if (ajaxCache && !ajaxCache.getOrGenerate) { 52 | ajaxCache.rules({ 53 | generateFunc: generateFunc 54 | }); 55 | } 56 | 57 | ajax.minimumCache = function() { 58 | return minimumCache; 59 | }; 60 | 61 | ajax.toJSON = function() { 62 | // Custom seralization so we don't have to bounce back and forth between object 63 | // and string representation 64 | // NOTE: This assumes that no URLS are going to have the literal " (or any other non-encoded 65 | // javascript illegal characters) 66 | return '{' 67 | + _.map(responses, function(value, key) { 68 | // Safe escape from causing invalid 69 | // early tag termination. 70 | var safeValue = value; 71 | if (safeValue) { 72 | // Multiple replace operations with string are apparently optimized in v8, vs. regexs 73 | // http://jsperf.com/javascript-multiple-replace/2 74 | safeValue = safeValue 75 | .replace(/<\//g, '<\\/') 76 | .replace(/ `FruitLoops.id` 97 | - `window._start` -> `FruitLoops.start` 98 | 99 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.10.0...v0.11.0) 100 | 101 | ## v0.10.0 - August 6th, 2014 102 | - Further isolate client from fruit loops code - e0cb0bd 103 | 104 | Compatibility notes: 105 | - Fruit Loops APIs have been moved to the window.FruitLoops object. Deprecated delegates are in place but will be removed prior to the 1.0 release. 106 | 107 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.9.2...v0.10.0) 108 | 109 | ## v0.9.2 - July 28th, 2014 110 | - [#38](https://github.com/walmartlabs/fruit-loops/issues/38) - serverCache can leave residual data if exists in content ([@kpdecker](https://api.github.com/users/kpdecker)) 111 | - Fix incorrect global replace in toJSON - 3ff398f 112 | - Push ajax test coverage to 100% - 50dfa77 113 | 114 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.9.1...v0.9.2) 115 | 116 | ## v0.9.1 - July 10th, 2014 117 | - Use _redirect flag rather than instanceOf - a27207e 118 | - Make RedirectError a consumable error - 461e201 119 | 120 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.9.0...v0.9.1) 121 | 122 | ## v0.9.0 - July 10th, 2014 123 | - Invalidate caching ajax requests on reset - c9fb8d8 124 | - Remove legacy catbox failover code - 2e45f9a 125 | - Add Storage.key implementation - bacabbe 126 | - Explicitly break cross context DOM links - 4dca494 127 | - Add dispose logic for jQuery objects - d4e4fcf 128 | 129 | Compatibility notes: 130 | - Catbox prior to 2.0 is no longer supported 131 | 132 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.8.0...v0.9.0) 133 | 134 | ## v0.8.0 - July 8th, 2014 135 | - [#35](https://github.com/walmartlabs/fruit-loops/issues/35) - Allow for custom status codes on response ([@kpdecker](https://api.github.com/users/kpdecker)) 136 | - Fix pending cleanup on script load error - 07e7557 137 | 138 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.7.1...v0.8.0) 139 | 140 | ## v0.7.1 - June 9th, 2014 141 | - Use setImmediate for beforeExec - f207080 142 | 143 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.7.0...v0.7.1) 144 | 145 | ## v0.7.0 - June 9th, 2014 146 | - Allow graceful error handling in beforeExec - f73a8c6 147 | - Handle $(function(){}) calls - c07a656 148 | - Provide user agent for ajax calls - 4039e9f 149 | - Handle additional dispose + loader race conditon - bdff9c7 150 | 151 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.5...v0.7.0) 152 | 153 | ## v0.6.5 - May 19th, 2014 154 | - NOP race condition with errors in loader - 49332b4 155 | - Catch throws from file resolvers and pass along - e295fb1 156 | 157 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.4...v0.6.5) 158 | 159 | ## v0.6.4 - May 12th, 2014 160 | - Do not run cleanup for errored views - 7f2f752 161 | - Improve error logging for inline scripts - 989c456 162 | - Remove newline from script output - b0fcbf5 163 | - Refactor pages and windows into named classes - 4dcd789 164 | 165 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.3...v0.6.4) 166 | 167 | ## v0.6.3 - May 5th, 2014 168 | - Use setImmediate to isolate client and loader code - 5a693c8 169 | 170 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.2...v0.6.3) 171 | 172 | ## v0.6.2 - April 25th, 2014 173 | - Add a few more cleanup cases in page dispose - d4ffe29, f107376 174 | - Safely handle syntax errors in scripts - 1ff7f12 175 | - Add isBoom flag for tracking of Errors - 9df9252 176 | - Simplify if conditional - bd3cdda 177 | 178 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.1...v0.6.2) 179 | 180 | ## v0.6.1 - April 22nd, 2014 181 | - Use empty string for empty location.search - e3e74d1 182 | 183 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.6.0...v0.6.1) 184 | 185 | ## v0.6.0 - April 21st, 2014 186 | - Expose pool metadata via pool.info api - a7da70e 187 | - Include usage data in page metadata - fc07c13 188 | 189 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.5...v0.6.0) 190 | 191 | ## v0.5.5 - April 11th, 2014 192 | - Stop page execution on redirect - 961c226 193 | - Fix global location assignment - 092f96e 194 | - Add comment to magic constant - 635d874 195 | 196 | Compatibility notes: 197 | - Any redirect operations will now throw a known error to prevent further execution of the thread. Clients may need to account for this in cleanup operations. 198 | 199 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.4...v0.5.5) 200 | 201 | ## v0.5.4 - April 11th, 2014 202 | - Perform explicit cleanup to avoid GC strain/bugs - cb2bfc0 203 | - Provide better names for module functions - 1545a79 204 | 205 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.3...v0.5.4) 206 | 207 | ## v0.5.3 - April 10th, 2014 208 | - Fix memory leak in defining window.location - 3f7da77 209 | 210 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.2...v0.5.3) 211 | 212 | ## v0.5.2 - April 10th, 2014 213 | - Add missing redirect callback to history module - edb476f 214 | 215 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.1...v0.5.2) 216 | 217 | ## v0.5.1 - April 8th, 2014 218 | - Remove $.get override - a2c837b 219 | - Fix buffer handling under new Cheerio - f50353f 220 | 221 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.5.0...v0.5.1) 222 | 223 | ## v0.5.0 - April 6th, 2014 224 | - Add cleanup callback for pool API - 2ce9c77 225 | - Remove console log on emit - a318950 226 | - Ensure ajax cache url does not change - 4cf33b0 227 | 228 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.4.0...v0.5.0) 229 | 230 | ## v0.4.0 - March 18th, 2014 231 | - Include response metadata in page callback - 3a112d2 232 | 233 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.3.0...v0.4.0) 234 | 235 | ## v0.3.0 - March 17th, 2014 236 | - [#32](https://github.com/walmartlabs/fruit-loops/pull/32) - Add sequence ID to emit calls to prevent race ([@kpdecker](https://api.github.com/users/kpdecker)) 237 | - [#31](https://github.com/walmartlabs/fruit-loops/pull/31) - Handle non-fully qualified ajax calls ([@kpdecker](https://api.github.com/users/kpdecker)) 238 | - Update for Catbox 2.1+ APIs - bd38d13 239 | 240 | Compatibility notes: 241 | - AJAX Cache users need to pass in a Catbox 2.1 object or use an adapter on 1.0 object. 242 | 243 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.2.2...v0.3.0) 244 | 245 | ## v0.2.2 - March 11th, 2014 246 | - Use catbox 1.x.x and hope we are dealing with that - 122d7fc 247 | - Relax catbox version requirements - 828be40 248 | - Fix spelling of detach method name - 63a6a61 249 | - Wrap beforeExec callback in a pending block - e228e2a 250 | 251 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.2.1...v0.2.2) 252 | 253 | ## v0.2.1 - March 4th, 2014 254 | - Use https protocol for cheerio dependency - 0901a8f 255 | - Remove testing comment - af4bdfe 256 | 257 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.2.0...v0.2.1) 258 | 259 | ## v0.2.0 - March 3rd, 2014 260 | - Initial funcitoning release 261 | 262 | Compatibility notes: 263 | - Numerous changes to APIs. Reviewing the documentation is highly recommended. 264 | 265 | [Commits](https://github.com/walmartlabs/fruit-loops/compare/v0.2.0...v0.2.0) 266 | 267 | ## v0.1.0 - January 7th, 2014 268 | 269 | - Initial release 270 | -------------------------------------------------------------------------------- /test/jquery/index.js: -------------------------------------------------------------------------------- 1 | var $ = require('../../lib/jquery'), 2 | Cheerio = require('cheerio'), 3 | dom = require('../../lib/dom'); 4 | 5 | describe('$', function() { 6 | var window, 7 | inst; 8 | beforeEach(function() { 9 | window = { 10 | eval: this.spy(), 11 | 12 | toString: function() { 13 | return '[object Window]'; 14 | }, 15 | nextTick: function(callback) { 16 | callback(); 17 | } 18 | }; 19 | window.self = window; 20 | window.document = { 21 | defaultView: window 22 | }; 23 | dom.navigator(window, {userAgent: ''}); 24 | inst = $(window, ''); 25 | }); 26 | 27 | describe('function object', function() { 28 | it('should work on function', function(done) { 29 | inst.$(done); 30 | }); 31 | }); 32 | 33 | describe('identify hotpath', function() { 34 | it('should return the identity', function() { 35 | var $el = inst.$('
'); 36 | $el.should.equal(inst.$($el)); 37 | }); 38 | }); 39 | 40 | describe('document object', function() { 41 | it('should accept document object', function() { 42 | inst.$(window.document).should.be.instanceOf(Cheerio); 43 | inst.$('document').should.be.instanceOf(Cheerio); 44 | }); 45 | describe('#ready', function() { 46 | it('should work on query', function(done) { 47 | inst.$('document').ready(done); 48 | }); 49 | it('should work on literal', function(done) { 50 | inst.$(window.document).ready(done); 51 | }); 52 | }); 53 | it('should fake event binding', function() { 54 | var doc = inst.$(window.document); 55 | doc.on('foo', function(){}).should.equal(doc); 56 | doc.off('foo', function(){}).should.equal(doc); 57 | }); 58 | it('should accept as context', function() { 59 | inst = $(window, '
'); 60 | inst.$('div', window.document).length.should.equal(1); 61 | }); 62 | }); 63 | describe('window object', function() { 64 | it('should accept window object', function() { 65 | inst.$(window).should.be.instanceOf(Cheerio); 66 | }); 67 | it('should fake event binding', function() { 68 | var doc = inst.$(window); 69 | doc.on('foo', function(){}).should.equal(doc); 70 | doc.off('foo', function(){}).should.equal(doc); 71 | }); 72 | it('should accept as context', function() { 73 | inst = $(window, '
'); 74 | inst.$('div', window).length.should.equal(1); 75 | }); 76 | }); 77 | 78 | describe('known caching', function() { 79 | it('should cache root', function() { 80 | inst = $(window, ''); 81 | inst.$('html', window).should.equal(inst.$(':root', window)); 82 | }); 83 | it('should cache head object', function() { 84 | inst = $(window, ''); 85 | inst.$('head', window).should.equal(inst.$('head', window)); 86 | 87 | inst = $(window, ''); 88 | inst.$('head', window).should.equal(inst.$('head', window)); 89 | }); 90 | it('should cache body object', function() { 91 | inst = $(window, ''); 92 | inst.$('body', window).should.equal(inst.$('body', window)); 93 | 94 | inst = $(window, ''); 95 | inst.$('body', window).should.equal(inst.$('body', window)); 96 | }); 97 | it('should not fail if there are no such objects', function() { 98 | inst.$('html', window).length.should.equal(0); 99 | inst.$('head', window).length.should.equal(0); 100 | inst.$('body', window).length.should.equal(0); 101 | 102 | inst = $(window, '
'); 103 | inst.$('html', window).length.should.equal(0); 104 | inst.$('head', window).length.should.equal(0); 105 | inst.$('body', window).length.should.equal(0); 106 | }); 107 | }); 108 | 109 | describe('$.fn', function() { 110 | it('should allow augmentation', function() { 111 | inst = $(window, '
'); 112 | window.$.fn.should.equal(inst.$.fn); 113 | inst.$.fn.foo = function() { 114 | return 'success'; 115 | }; 116 | inst.$('div').foo().should.equal('success'); 117 | should.not.exist(inst.$('div').bar); 118 | 119 | inst.$.fn.bar = function() { 120 | return 'success'; 121 | }; 122 | inst.$('div').bar().should.equal('success'); 123 | inst.$('div').find('span').bar().should.equal('success'); 124 | 125 | inst = $(window, '
'); 126 | should.not.exist(inst.$('div').foo); 127 | should.not.exist(inst.$('div').bar); 128 | }); 129 | }); 130 | 131 | describe('#each', function() { 132 | it('should iterate arrays', function() { 133 | var spy = this.spy(function(i, value) { 134 | this.should.equal(value); 135 | value.should.equal((i+1)*10); 136 | }); 137 | inst.$.each([10, 20, 30, 40], spy); 138 | spy.callCount.should.equal(4); 139 | }); 140 | it('should iterate objects', function() { 141 | var spy = this.spy(function(key, value) { 142 | this.should.equal(value); 143 | if (key === 'a') { 144 | value.should.equal(10); 145 | } else if (key === 'b') { 146 | value.should.equal(20); 147 | } else if (key === 'c') { 148 | value.should.equal(30); 149 | } else if (key === 'd') { 150 | value.should.equal(40); 151 | } else { 152 | throw new Error(); 153 | } 154 | }); 155 | inst.$.each({'a':10, 'b':20, 'c':30, 'd':40}, spy); 156 | spy.callCount.should.equal(4); 157 | }); 158 | it('should terminate early', function() { 159 | var spy = this.spy(function(i, value) { 160 | this.should.equal(value); 161 | if (i === 2) { 162 | return false; 163 | } 164 | }); 165 | inst.$.each([10, 20, 30, 40], spy); 166 | spy.callCount.should.equal(3); 167 | }); 168 | }); 169 | 170 | describe('#extend', function() { 171 | it('should extend the object', function() { 172 | inst.$.extend({}, {'foo': 'bar'}, {'baz': 'bat'}).should.eql({ 173 | 'foo': 'bar', 174 | 'baz': 'bat' 175 | }); 176 | }); 177 | it('should handle deep parameter', function() { 178 | inst.$.extend(true, {}, {'foo': [1,2,3,4], 'bat': {'bat': true}}, {'foo': [3,4], 'bat': {baz: 'bar'}}).should.eql({ 179 | 'foo': [3,4,3,4], 180 | 'bat': {bat: true, baz: 'bar'} 181 | }); 182 | }); 183 | }); 184 | describe('#globalEval', function() { 185 | it('should execute eval', function() { 186 | inst.$.globalEval('foo'); 187 | window.eval.should.have.been.calledWith('foo'); 188 | }); 189 | }); 190 | describe('#grep', function() { 191 | it('should filter', function() { 192 | inst.$.grep([1,2,3], function(value) { return value > 1; }).should.eql([2,3]); 193 | }); 194 | }); 195 | describe('#inArray', function() { 196 | it('should look up elements', function() { 197 | inst.$.inArray(1, [1,2,3]).should.equal(0); 198 | inst.$.inArray(2, [1,2,3]).should.equal(1); 199 | }); 200 | it('should handle missing', function() { 201 | inst.$.inArray(4, [1,2,3]).should.equal(-1); 202 | }); 203 | it('should handle fromIndex', function() { 204 | inst.$.inArray(1, [1,2,3], 0).should.equal(0); 205 | inst.$.inArray(1, [1,2,3], 1).should.equal(-1); 206 | }); 207 | }); 208 | 209 | describe('#isArray', function() { 210 | it('should handle arrays', function() { 211 | inst.$.isArray([]).should.be.true; 212 | }); 213 | it('should handle non-arrays', function() { 214 | inst.$.isArray({}).should.be.false; 215 | }); 216 | }); 217 | describe('#isFunction', function() { 218 | it('should handle functions', function() { 219 | inst.$.isFunction(function() {}).should.be.true; 220 | }); 221 | it('should handle non-functions', function() { 222 | inst.$.isFunction({}).should.be.false; 223 | }); 224 | }); 225 | describe('#isNumeric', function() { 226 | it('should handle number', function() { 227 | inst.$.isNumeric(4).should.be.true; 228 | inst.$.isNumeric(new Number(4)).should.be.true; 229 | }); 230 | it('should handle non-number', function() { 231 | inst.$.isNumeric({}).should.be.false; 232 | }); 233 | }); 234 | describe('#isEmptyObject', function() { 235 | it('should handle empty objects', function() { 236 | inst.$.isEmptyObject({}).should.be.true; 237 | }); 238 | it('should handle non-empty objects', function() { 239 | inst.$.isEmptyObject({foo: true}).should.be.false; 240 | inst.$.isEmptyObject([]).should.be.false; 241 | inst.$.isEmptyObject(1).should.be.false; 242 | inst.$.isEmptyObject(window).should.be.false; 243 | inst.$.isEmptyObject(window.document).should.be.false; 244 | inst.$.isEmptyObject(inst.$(window.document)).should.be.false; 245 | }); 246 | }); 247 | describe('#isPlainObject', function() { 248 | it('should handle plain objects', function() { 249 | inst.$.isPlainObject({}).should.be.true; 250 | inst.$.isPlainObject({foo: true}).should.be.true; 251 | }); 252 | it('should handle non-plain objects', function() { 253 | inst.$.isPlainObject(1).should.be.false; 254 | inst.$.isPlainObject(window).should.be.false; 255 | inst.$.isPlainObject(window.document).should.be.false; 256 | inst.$.isPlainObject(inst.$(window.document)).should.be.false; 257 | }); 258 | }); 259 | describe('#isWindow', function() { 260 | it('should handle window', function() { 261 | inst.$.isWindow(window).should.be.true; 262 | }); 263 | it('should handle non-window', function() { 264 | inst.$.isWindow({}).should.be.false; 265 | }); 266 | }); 267 | describe('#type', function() { 268 | it('should handle "undefined"', function() { 269 | inst.$.type(undefined).should.equal('undefined'); 270 | inst.$.type().should.equal('undefined'); 271 | inst.$.type(window.notDefined).should.equal('undefined'); 272 | }); 273 | it('should handle "null"', function() { 274 | inst.$.type(null).should.equal('null'); 275 | }); 276 | it('should handle "boolean"', function() { 277 | inst.$.type(true).should.equal('boolean'); 278 | inst.$.type(new Boolean()).should.equal('boolean'); 279 | }); 280 | it('should handle "number"', function() { 281 | inst.$.type(3).should.equal('number'); 282 | inst.$.type(new Number(3)).should.equal('number'); 283 | }); 284 | it('should handle "string"', function() { 285 | inst.$.type("test").should.equal('string'); 286 | inst.$.type(new String("test")).should.equal('string'); 287 | }); 288 | it('should handle "function"', function() { 289 | inst.$.type(function(){}).should.equal('function'); 290 | }); 291 | it('should handle "array"', function() { 292 | inst.$.type([]).should.equal('array'); 293 | }); 294 | it('should handle "array"', function() { 295 | inst.$.type(new Array()).should.equal('array'); 296 | }); 297 | it('should handle "date"', function() { 298 | inst.$.type(new Date()).should.equal('date'); 299 | }); 300 | it('should handle "error"', function() { 301 | inst.$.type(new Error()).should.equal('error'); 302 | }); 303 | it('should handle "regexp"', function() { 304 | inst.$.type(/test/).should.equal('regexp'); 305 | }); 306 | it('should handle "window"', function() { 307 | inst.$.type(window).should.equal('window'); 308 | }); 309 | }); 310 | 311 | describe('#merge', function() { 312 | it('should merge arrays', function() { 313 | var arr = [1,2,3]; 314 | inst.$.merge(arr, [4,5]).should.equal(arr); 315 | arr.should.eql([1,2,3,4,5]); 316 | }); 317 | }); 318 | 319 | describe('#now', function() { 320 | it('should return current time', function() { 321 | var base = Date.now(); 322 | inst.$.now().should.equal(base); 323 | this.clock.tick(123); 324 | inst.$.now().should.equal(base + 123); 325 | }); 326 | }); 327 | 328 | describe('#param', function() { 329 | it('should return params', function() { 330 | inst.$.param({foo: ' bar', baz: 'bat='}).should.equal('foo=%20bar&baz=bat%3D'); 331 | }); 332 | }); 333 | 334 | describe('#proxy', function() { 335 | it('should bind function', function() { 336 | var context = {}, 337 | spy = this.spy(); 338 | inst.$.proxy(spy, context, 1, 2)(); 339 | spy.should.have.been.calledOn(context); 340 | spy.should.have.been.calledWith(1, 2); 341 | }); 342 | it('should bind key function', function() { 343 | var spy = this.spy(), 344 | context = {key: spy}; 345 | 346 | inst.$.proxy(context, 'key', 1, 2)(); 347 | spy.should.have.been.calledOn(context); 348 | spy.should.have.been.calledWith(1, 2); 349 | }); 350 | }); 351 | 352 | describe('#trim', function() { 353 | it('should trim', function() { 354 | inst.$.trim(' foo ').should.equal('foo'); 355 | }); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /lib/page.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | async = require('async'), 3 | Contextify = require('contextify'), 4 | dom = require('./dom'), 5 | Exec = require('./exec'), 6 | fs = require('fs'), 7 | jQuery = require('./jquery'), 8 | path = require('path'); 9 | 10 | var pageCache = {}, 11 | scriptCache = {}, 12 | windowId = 0; 13 | 14 | // Load the client mode scripts. 15 | // Load sync here as it's only done on init and we are loading from a known local file. This 16 | // is somewhat akin to a deferred require in that manner. 17 | var ClientScripts = [ 18 | __dirname + '/bootstrap/window.js' 19 | ].map(function(href) { 20 | var src = fs.readFileSync(href); 21 | return Contextify.createScript(src.toString(), href); 22 | }); 23 | 24 | module.exports = exports = function(options) { 25 | var context = new Window(), 26 | _id = windowId++, 27 | 28 | // We need to set this up here to ensure that the defineProperty propagates properly to the 29 | // context 30 | // https://github.com/joyent/node/commit/3c5ea410ca56da3d4785e2563cb2724364669fd2 31 | locationPreInit = dom.location.preInit(context); 32 | 33 | context = Contextify.createContext(context); 34 | 35 | var host = options.host || 'localhost', 36 | protocol = options.protocol || 'http:', 37 | callback = options.callback, 38 | emitCallbacks = [], 39 | scripts, 40 | 41 | exec = Exec.create(_callback), 42 | pending = exec.pending, 43 | window = context.getGlobal(), 44 | requestId = 0, 45 | pageCount = 1, // Number of times this page has navigated 46 | $, 47 | 48 | status = 200; 49 | 50 | // Primary external API 51 | var FruitLoops = { 52 | id: _id, 53 | start: process.hrtime(), 54 | 55 | hrtime: function(start) { 56 | // The c code impl of hrtime expects a literal number of arguments. 57 | if (start) { 58 | return process.hrtime(start); 59 | } else { 60 | return process.hrtime(); 61 | } 62 | }, 63 | 64 | redirect: function(url) { 65 | _callback(undefined, {redirect: url}); 66 | 67 | // Cancel futher exec 68 | throw new exec.RedirectError(url); 69 | }, 70 | statusCode: function(_status) { 71 | status = _status; 72 | }, 73 | 74 | emit: function(after) { 75 | function checkComplete() { 76 | if (isComplete()) { 77 | var associatedRequestId = requestId; 78 | setImmediate(function() { 79 | // Avoid a race condtion where pooled requests may come in while we still have 80 | // pending emit calls. This will generally only happen for very chatty emit callers. 81 | if (requestId === associatedRequestId) { 82 | emit(); 83 | } 84 | }); 85 | } 86 | } 87 | function isComplete() { 88 | if (after === 'ajax') { 89 | return $.ajax.allComplete(); 90 | } else if (after === 'events') { 91 | return !pending.pending(); 92 | } else { 93 | // If we are in immediate mode (i.e. the default behavior) then we 94 | // always consider ourselves complete. 95 | return true; 96 | } 97 | } 98 | 99 | if (!callback) { 100 | // This should be considered a framework error as the pending tracker 101 | // should prevent this from happening. 102 | throw new Error('Emit outside of request: ' + _id); 103 | } 104 | 105 | if (after === 'ajax') { 106 | $.ajax.once('complete', checkComplete); 107 | } else if (after === 'events') { 108 | pending.on('pop', checkComplete); 109 | } 110 | 111 | // If it doesn't look like anything is running or we have an explicit emit 112 | // then defer the exec and emit if nothing comes into the queue for the remainder 113 | // of the tick. 114 | if (isComplete()) { 115 | setImmediate(function() { 116 | checkComplete(); 117 | }); 118 | } 119 | }, 120 | onEmit: function(callback) { 121 | emitCallbacks.push(callback); 122 | }, 123 | 124 | loadInContext: function(href, callback) { 125 | try { 126 | if (options.resolver) { 127 | href = options.resolver(href, page); 128 | } else { 129 | href = path.resolve(path.join(path.dirname(options.index), href)); 130 | } 131 | } catch (err) { 132 | return callback(err); 133 | } 134 | 135 | var loaded = pending.wrap('load', href, function(err) { 136 | if (!context) { 137 | // We've been disposed, but there was something on the setImmediate list, 138 | // silently ignore 139 | return; 140 | } 141 | if (err) { 142 | return callback(err); 143 | } 144 | 145 | exec.exec(function() { 146 | script.runInContext(context); 147 | 148 | callback && callback(); 149 | }, callback); 150 | }); 151 | 152 | var script = scriptCache[href]; 153 | if (!script) { 154 | fs.readFile(href, function(err, src) { 155 | if (!context) { 156 | // Another disposed race condition. NOP 157 | return; 158 | } 159 | 160 | if (err) { 161 | return loaded(err); 162 | } 163 | 164 | try { 165 | script = Contextify.createScript(src.toString(), href); 166 | } catch (err) { 167 | return loaded(err); 168 | } 169 | 170 | if (options.cacheResources) { 171 | scriptCache[href] = script; 172 | } 173 | 174 | // Pop off the stack here so any code that might cause an Error.stack 175 | // retain doesn't retain on the fs buffers, etc. This is a horrible reason 176 | // to do this but it does help isolate the host and client code. 177 | setImmediate(loaded); 178 | }); 179 | } else { 180 | setImmediate(loaded); 181 | } 182 | } 183 | }; 184 | 185 | window.FruitLoops = FruitLoops; 186 | 187 | ClientScripts.forEach(function(script) { 188 | script.runInContext(context); 189 | }); 190 | 191 | var location = dom.location(window, protocol + '//' + host + options.path); 192 | 193 | var toReset = [ 194 | dom.performance(window), 195 | dom.storage(window, 'localStorage'), 196 | dom.storage(window, 'sessionStorage'), 197 | ]; 198 | var toCleanup = toReset.concat([ 199 | dom.async(window, exec), 200 | dom.console(window, exec), 201 | dom.dynamic(window, options), 202 | dom.history(window), 203 | dom.navigator(window, options), 204 | locationPreInit, 205 | location 206 | ]); 207 | 208 | function emit() { 209 | if (!callback) { 210 | // A pending emit that has already completed 211 | return; 212 | } 213 | 214 | $.$('script').remove(); 215 | 216 | // Emit error if any of these fail. 217 | try { 218 | emitCallbacks.forEach(function(callback) { 219 | callback(); 220 | }); 221 | } catch (err) { 222 | return _callback(exec.processError(err)); 223 | } 224 | 225 | // Inline any script content that we may have received 226 | // We are using text here for two reasons. The first is that it ensures that content like 227 | // doesn't create multiple elements and the second is that it removes parser overhead 228 | // when constructing the cheerio object due to the parser iterating ove the JSON content itself. 229 | var serverCache = $.$(''); 230 | serverCache.text('var $serverCache = ' + $.ajax.toJSON() + ';'); 231 | $.$('body').append(serverCache); 232 | 233 | // Ensure sure that we have all of our script content and that it it at the end of the document 234 | // this has two benefits: the body element may be rendered to directly and this will push 235 | // all of the scripts after the content elements 236 | $.$('body').append(scripts); 237 | 238 | options.finalize && options.finalize(page); 239 | 240 | // And output the thing 241 | _callback(undefined, $.root.html()); 242 | } 243 | 244 | function _callback(err, data) { 245 | if (!callback) { 246 | // This should be considered a framework error as the pending tracker 247 | // should prevent this from happening. 248 | var hint = err ? (err.stack || err.toString()) : data.toString().substr(0, 100); 249 | throw new Error('Emit outside of request: ' + _id + ' ' + hint); 250 | } 251 | 252 | var minimumCache, 253 | incompleteTasks = pending.pending(), 254 | taskLog = pending.log(), 255 | maxTasks = pending.maxPending(); 256 | if ($) { 257 | minimumCache = $.ajax.minimumCache(); 258 | } 259 | 260 | // Kill off anything that may be pending as well as all logs, etc 261 | pending.reset(); 262 | if ($ && $.ajax) { 263 | $.ajax.reset(); 264 | } 265 | 266 | // Invalidate our callback so if there is a bug and future callback attempts occur we will 267 | // fail using the check above. 268 | var _callback = callback; 269 | callback = undefined; 270 | 271 | _callback(err, data, { 272 | status: status, 273 | cache: minimumCache, 274 | pageId: _id, 275 | pageCount: pageCount, 276 | 277 | taskLog: taskLog, 278 | incompleteTasks: incompleteTasks, 279 | maxTasks: maxTasks 280 | }); 281 | 282 | status = 200; 283 | } 284 | 285 | function loadPage(src) { 286 | $ = page.$ = jQuery(window, src, exec, options); 287 | toCleanup.push($); 288 | 289 | pending.push('beforeExec', 1); 290 | if (options.beforeExec) { 291 | setImmediate(function() { 292 | options.beforeExec(page, loadScripts); 293 | }); 294 | } else { 295 | loadScripts(); 296 | } 297 | } 298 | function loadScripts(err) { 299 | if (err) { 300 | return _callback(err); 301 | } 302 | 303 | pending.pop('beforeExec', 1); 304 | 305 | scripts = $.$('script'); 306 | 307 | var loaders = _.map(scripts, function(script, i) { 308 | return pending.wrap('script', i, function(callback) { 309 | 310 | var el = $.$(script), 311 | text = el.text(), 312 | external = el.attr('src'); 313 | 314 | if (external) { 315 | FruitLoops.loadInContext(external, callback); 316 | } else { 317 | page.runScript(text, callback); 318 | } 319 | }); 320 | }); 321 | 322 | async.series(loaders, function(err) { 323 | if (err) { 324 | if (err._redirect) { 325 | return; 326 | } 327 | 328 | _callback(err); 329 | } else { 330 | try { 331 | options.loaded && options.loaded(page); 332 | } catch (err) { 333 | if (err._redirect) { 334 | return; 335 | } else { 336 | _callback(err); 337 | } 338 | } 339 | } 340 | }); 341 | } 342 | 343 | var page = new Page(); 344 | _.extend(page, { 345 | id: _id, 346 | window: window, 347 | $: $, 348 | exec: _.bind(exec.exec, exec), 349 | rewriteStack: _.bind(exec.rewriteStack, exec), 350 | runScript: function(text, callback) { 351 | exec.exec(function() { 352 | try { 353 | context.run(text, text); 354 | } catch (err) { 355 | var stack = err.stack, 356 | toThrow = /SyntaxError/.test(stack) ? new SyntaxError(err.message) : new Error(err.message); 357 | toThrow.stack = stack.split(/\n/)[0] + '\n\nInline Script:\n\t'; 358 | toThrow._redirect = err._redirect; 359 | throw toThrow; 360 | } 361 | 362 | callback(); 363 | }, callback); 364 | }, 365 | 366 | emit: FruitLoops.emit, 367 | pending: pending, 368 | 369 | metadata: options.metadata, 370 | 371 | dispose: function() { 372 | // Reset anything pending should we happen to be disposed of outside of an emit response 373 | pending.reset(); 374 | if ($ && $.ajax) { 375 | $.ajax.reset(); 376 | } 377 | 378 | _.each(toCleanup, function(toCleanup) { 379 | toCleanup.dispose(); 380 | }); 381 | 382 | emitCallbacks.length = 0; 383 | 384 | options = callback = 385 | window = context = 386 | toReset = toCleanup = location = $ = 387 | window.FruitLoops = 388 | emitCallbacks = scripts = 389 | page.metadata = page.window = page.emit = page.$ = undefined; 390 | }, 391 | 392 | navigate: function(path, metadata, _callback) { 393 | requestId++; 394 | pageCount++; 395 | FruitLoops.start = process.hrtime(); 396 | 397 | callback = _callback || metadata; 398 | page.metadata = _callback ? metadata : undefined; 399 | 400 | _.each(toReset, function(toReset) { 401 | toReset.reset(); 402 | }); 403 | 404 | location.reset(protocol + '//' + host + path); 405 | } 406 | }); 407 | 408 | if (pageCache[options.index]) { 409 | loadPage(pageCache[options.index]); 410 | } else { 411 | fs.readFile(options.index, function(err, src) { 412 | if (err) { 413 | return _callback(err); 414 | } 415 | 416 | // Buffer -> String 417 | src = src.toString(); 418 | 419 | if (options.cacheResources) { 420 | pageCache[options.index] = src; 421 | } 422 | 423 | loadPage(src); 424 | }); 425 | } 426 | 427 | return page; 428 | }; 429 | 430 | 431 | function Window() { 432 | } 433 | 434 | function Page() { 435 | } 436 | -------------------------------------------------------------------------------- /test/pool.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | FruitLoops = require('../lib'), 3 | fs = require('fs'); 4 | 5 | describe('#pool', function() { 6 | var pool; 7 | afterEach(function() { 8 | pool && pool.dispose(); 9 | pool = undefined; 10 | }); 11 | 12 | it('should serve pages on navigate', function(done) { 13 | var emitCalled; 14 | 15 | pool = FruitLoops.pool({ 16 | poolSize: 2, 17 | host: 'winning', 18 | index: __dirname + '/artifacts/pool-page.html', 19 | loaded: function(page) { 20 | page.window.$.should.exist; 21 | page.window.$serverSide.should.be.true; 22 | page.window.loadedCallback = true; 23 | }, 24 | navigated: function(page, existingPage) { 25 | existingPage.should.be.false; 26 | page.metadata.should.equal('meta!'); 27 | 28 | page.window.navigated(); 29 | page.emit('events'); 30 | }, 31 | callback: function() { 32 | throw new Error('should not be called'); 33 | }, 34 | cleanup: function() { 35 | emitCalled.should.be.true; 36 | done(); 37 | } 38 | }); 39 | pool.navigate('/bar', 'meta!', function(err, html) { 40 | emitCalled = true; 41 | should.not.exist(err); 42 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 43 | }); 44 | }); 45 | it('should create up to poolSize VMs', function(done) { 46 | this.clock.restore(); 47 | 48 | function _done() { 49 | returned++; 50 | if (returned >= 2) { 51 | _.keys(ids).length.should.equal(2); 52 | 53 | done(); 54 | } 55 | } 56 | 57 | var ids = {}, 58 | returned = 0; 59 | 60 | pool = FruitLoops.pool({ 61 | poolSize: 2, 62 | host: 'winning', 63 | index: __dirname + '/artifacts/pool-page.html', 64 | loaded: function(page) { 65 | page.window.$.should.exist; 66 | page.window.$serverSide.should.be.true; 67 | page.window.loadedCallback = true; 68 | 69 | ids[page.window.FruitLoops.id] = true; 70 | }, 71 | navigated: function(page, existingPage) { 72 | existingPage.should.be.false; 73 | 74 | page.window.navigated(); 75 | setTimeout(function() { 76 | page.emit('events'); 77 | }, 10); 78 | }, 79 | callback: function() { 80 | throw new Error('should not be called'); 81 | } 82 | }); 83 | pool.navigate('/bar', function(err, html) { 84 | should.not.exist(err); 85 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 86 | 87 | _done(); 88 | }); 89 | pool.navigate('/baz', function(err, html) { 90 | should.not.exist(err); 91 | html.should.match(/"location-info">http:\/\/winning\/baz true<\/div>/); 92 | 93 | _done(); 94 | }); 95 | }); 96 | it('should queue requests above the pool size', function(done) { 97 | this.clock.restore(); 98 | 99 | function _done() { 100 | returned++; 101 | if (returned >= 3) { 102 | _.keys(ids).length.should.equal(2); 103 | 104 | done(); 105 | } 106 | } 107 | 108 | var ids = {}, 109 | navigated = 0, 110 | returned = 0; 111 | 112 | pool = FruitLoops.pool({ 113 | poolSize: 2, 114 | host: 'winning', 115 | index: __dirname + '/artifacts/pool-page.html', 116 | loaded: function(page) { 117 | page.window.$.should.exist; 118 | page.window.$serverSide.should.be.true; 119 | page.window.loadedCallback = true; 120 | 121 | ids[page.window.FruitLoops.id] = true; 122 | }, 123 | navigated: function(page, existingPage) { 124 | existingPage.should.equal(++navigated > 2); 125 | 126 | page.window.navigated(); 127 | setTimeout(function() { 128 | page.emit('events'); 129 | }, 10); 130 | }, 131 | callback: function() { 132 | throw new Error('should not be called'); 133 | } 134 | }); 135 | pool.navigate('/bar', function(err, html, meta) { 136 | should.not.exist(err); 137 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 138 | meta.status.should.equal(404); 139 | 140 | _done(); 141 | }); 142 | pool.navigate('/baz', function(err, html, meta) { 143 | should.not.exist(err); 144 | html.should.match(/"location-info">http:\/\/winning\/baz true<\/div>/); 145 | meta.status.should.equal(200); 146 | 147 | _done(); 148 | }); 149 | pool.navigate('/bat', function(err, html, meta) { 150 | should.not.exist(err); 151 | html.should.match(/"location-info">http:\/\/winning\/bat true<\/div>/); 152 | meta.status.should.equal(200); 153 | 154 | pool.info().should.eql({queued: 0, pages: 2, free: 1}); 155 | 156 | _done(); 157 | }); 158 | }); 159 | it('should reject requests above the queue size', function(done) { 160 | this.clock.restore(); 161 | 162 | function _done() { 163 | returned++; 164 | if (returned >= 3) { 165 | _.keys(ids).length.should.equal(1); 166 | 167 | done(); 168 | } 169 | } 170 | 171 | var ids = {}, 172 | navigated = 0, 173 | returned = 0; 174 | 175 | pool = FruitLoops.pool({ 176 | poolSize: 1, 177 | maxQueue: 1, 178 | host: 'winning', 179 | index: __dirname + '/artifacts/pool-page.html', 180 | loaded: function(page) { 181 | page.window.$.should.exist; 182 | page.window.$serverSide.should.be.true; 183 | page.window.loadedCallback = true; 184 | 185 | ids[page.window.FruitLoops.id] = true; 186 | }, 187 | navigated: function(page, existingPage) { 188 | existingPage.should.equal(++navigated > 1); 189 | 190 | page.window.navigated(); 191 | setTimeout(function() { 192 | page.emit('events'); 193 | }, 10); 194 | }, 195 | callback: function() { 196 | throw new Error('should not be called'); 197 | } 198 | }); 199 | pool.navigate('/bar', function(err, html, meta) { 200 | should.not.exist(err); 201 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 202 | meta.status.should.equal(404); 203 | 204 | _done(); 205 | }); 206 | pool.navigate('/baz', function(err, html, meta) { 207 | should.not.exist(err); 208 | html.should.match(/"location-info">http:\/\/winning\/baz true<\/div>/); 209 | meta.status.should.equal(200); 210 | 211 | _done(); 212 | }); 213 | pool.navigate('/bat', function(err, html, meta) { 214 | err.should.match(/EQUEUEFULL/); 215 | should.not.exist(html); 216 | should.not.exist(meta); 217 | 218 | pool.info().should.eql({queued: 1, pages: 1, free: 0}); 219 | 220 | _done(); 221 | }); 222 | }); 223 | it('should timeout requests in queue', function(done) { 224 | this.clock.restore(); 225 | 226 | function _done() { 227 | returned++; 228 | if (returned >= 2) { 229 | _.keys(ids).length.should.equal(1); 230 | 231 | done(); 232 | } 233 | } 234 | 235 | var ids = {}, 236 | navigated = 0, 237 | returned = 0; 238 | 239 | pool = FruitLoops.pool({ 240 | poolSize: 1, 241 | queueTimeout: 10, 242 | host: 'winning', 243 | index: __dirname + '/artifacts/pool-page.html', 244 | loaded: function(page) { 245 | page.window.$.should.exist; 246 | page.window.$serverSide.should.be.true; 247 | page.window.loadedCallback = true; 248 | 249 | ids[page.window.FruitLoops.id] = true; 250 | }, 251 | navigated: function(page, existingPage) { 252 | existingPage.should.equal(++navigated > 1); 253 | 254 | page.window.navigated(); 255 | setTimeout(function() { 256 | page.emit('events'); 257 | }, 100); 258 | }, 259 | callback: function() { 260 | throw new Error('should not be called'); 261 | } 262 | }); 263 | pool.navigate('/bar', function(err, html, meta) { 264 | should.not.exist(err); 265 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 266 | meta.status.should.equal(404); 267 | 268 | _done(); 269 | }); 270 | pool.navigate('/bat', function(err, html, meta) { 271 | err.should.match(/EQUEUETIMEOUT/); 272 | should.not.exist(html); 273 | should.not.exist(meta); 274 | 275 | pool.info().should.eql({queued: 0, pages: 1, free: 0}); 276 | 277 | _done(); 278 | }); 279 | }); 280 | it('should not-timeout requests in queue', function(done) { 281 | this.clock.restore(); 282 | 283 | function _done() { 284 | returned++; 285 | if (returned >= 2) { 286 | _.keys(ids).length.should.equal(1); 287 | 288 | done(); 289 | } 290 | } 291 | 292 | var ids = {}, 293 | navigated = 0, 294 | returned = 0; 295 | 296 | pool = FruitLoops.pool({ 297 | poolSize: 1, 298 | queueTimeout: 100, 299 | host: 'winning', 300 | index: __dirname + '/artifacts/pool-page.html', 301 | loaded: function(page) { 302 | page.window.$.should.exist; 303 | page.window.$serverSide.should.be.true; 304 | page.window.loadedCallback = true; 305 | 306 | ids[page.window.FruitLoops.id] = true; 307 | }, 308 | navigated: function(page, existingPage) { 309 | existingPage.should.equal(++navigated > 1); 310 | 311 | page.window.navigated(); 312 | setTimeout(function() { 313 | page.emit('events'); 314 | }, 10); 315 | }, 316 | callback: function() { 317 | throw new Error('should not be called'); 318 | } 319 | }); 320 | pool.navigate('/bar', function(err, html, meta) { 321 | should.not.exist(err); 322 | html.should.match(/"location-info">http:\/\/winning\/bar true<\/div>/); 323 | meta.status.should.equal(404); 324 | 325 | _done(); 326 | }); 327 | pool.navigate('/bat', function(err, html, meta) { 328 | should.not.exist(err); 329 | html.should.match(/"location-info">http:\/\/winning\/bat true<\/div>/); 330 | meta.status.should.equal(200); 331 | 332 | pool.info().should.eql({queued: 0, pages: 1, free: 0}); 333 | 334 | _done(); 335 | }); 336 | }); 337 | it('should invalidate pages on error', function(done) { 338 | this.clock.restore(); 339 | 340 | function _done() { 341 | returned++; 342 | if (returned >= 3) { 343 | _.keys(ids).length.should.equal(3); 344 | 345 | setImmediate(function() { 346 | pool.info().should.eql({ 347 | queued: 0, 348 | pages: 0, 349 | free: 0 350 | }); 351 | 352 | done(); 353 | }); 354 | } 355 | } 356 | 357 | var ids = {}, 358 | returned = 0; 359 | 360 | pool = FruitLoops.pool({ 361 | poolSize: 2, 362 | host: 'winning', 363 | index: __dirname + '/artifacts/pool-page.html', 364 | loaded: function(page) { 365 | page.window.$.should.exist; 366 | page.window.$serverSide.should.be.true; 367 | page.window.loadedCallback = true; 368 | 369 | ids[page.window.FruitLoops.id] = true; 370 | }, 371 | navigated: function(page, existingPage) { 372 | existingPage.should.be.false; 373 | 374 | page.window.navigated(); 375 | page.window.setTimeout(function() { 376 | throw new Error('Errored!'); 377 | }, 10); 378 | }, 379 | callback: function() { 380 | throw new Error('should not be called'); 381 | } 382 | }); 383 | pool.navigate('/bar', function(err, html) { 384 | err.toString().should.match(/Errored!/); 385 | _done(); 386 | }); 387 | pool.navigate('/baz', function(err, html) { 388 | err.toString().should.match(/Errored!/); 389 | _done(); 390 | }); 391 | pool.navigate('/bat', function(err, html) { 392 | err.toString().should.match(/Errored!/); 393 | _done(); 394 | }); 395 | }); 396 | it('should reset on watch change', function(done) { 397 | this.clock.restore(); 398 | 399 | var watchCallback; 400 | this.stub(fs, 'watch', function(fileName, options, callback) { 401 | watchCallback = callback; 402 | return { close: function() {} }; 403 | }); 404 | 405 | var ids = {}; 406 | 407 | pool = FruitLoops.pool({ 408 | poolSize: 2, 409 | host: 'winning', 410 | index: __dirname + '/artifacts/script-page.html', 411 | loaded: function(page) { 412 | ids[page.window.FruitLoops.id] = true; 413 | }, 414 | navigated: function(page, existingPage) { 415 | existingPage.should.be.false; 416 | 417 | ids[page.window.FruitLoops.id] = true; 418 | 419 | page.window.emit(); 420 | } 421 | }); 422 | pool.navigate('/bar', function(err, html) { 423 | setImmediate(function() { 424 | watchCallback(); 425 | 426 | pool.navigate('/baz', function(err, html) { 427 | _.keys(ids).length.should.equal(2); 428 | done(); 429 | }); 430 | }); 431 | }); 432 | }); 433 | it('should error with incorrect args', function() { 434 | should.Throw(function() { 435 | FruitLoops.pool({ 436 | loaded: function(page) { 437 | throw new Error('should not be called'); 438 | }, 439 | navigated: function(page) { 440 | throw new Error('should not be called'); 441 | }, 442 | callback: function() { 443 | throw new Error('should not be called'); 444 | } 445 | }); 446 | }, Error, /Must pass in a poolSize value/); 447 | }); 448 | }); 449 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | *** 2 | # NOTICE: 3 | 4 | ## This repository has been archived and is not supported. 5 | 6 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 7 | *** 8 | NOTICE: SUPPORT FOR THIS PROJECT HAS ENDED 9 | 10 | This projected was owned and maintained by Walmart. This project has reached its end of life and Walmart no longer supports this project. 11 | 12 | We will no longer be monitoring the issues for this project or reviewing pull requests. You are free to continue using this project under the license terms or forks of this project at your own risk. This project is no longer subject to Walmart's bug bounty program or other security monitoring. 13 | 14 | 15 | ## Actions you can take 16 | 17 | We recommend you take the following action: 18 | 19 | * Review any configuration files used for build automation and make appropriate updates to remove or replace this project 20 | * Notify other members of your team and/or organization of this change 21 | * Notify your security team to help you evaluate alternative options 22 | 23 | ## Forking and transition of ownership 24 | 25 | For [security reasons](https://www.theregister.co.uk/2018/11/26/npm_repo_bitcoin_stealer/), Walmart does not transfer the ownership of our primary repos on Github or other platforms to other individuals/organizations. Further, we do not transfer ownership of packages for public package management systems. 26 | 27 | If you would like to fork this package and continue development, you should choose a new name for the project and create your own packages, build automation, etc. 28 | 29 | Please review the licensing terms of this project, which continue to be in effect even after decommission. 30 | 31 | # Fruit Loops 32 | 33 | Provides a performant jQuery-like environment for rendering of client-side SPA application within node servers. 34 | 35 | ## Example 36 | 37 | ```javascript 38 | FruitLoops.page({ 39 | index: __dirname + '/index.html', 40 | 41 | host: 'www.github.com', 42 | path: '/foo/bar', 43 | 44 | resolver: function(href, page) { 45 | // Add -server suffix to load the server-specific build. 46 | if (href && !/-server\.js^/.test(href)) { 47 | href = href.replace(/\.js$/, '-server.js'); 48 | } 49 | 50 | // Remap the external URL space to the local file system 51 | if (href) { 52 | href = path.relative('/r/phoenix/', href); 53 | href = __dirname + '/src/' + href; 54 | } 55 | 56 | return href; 57 | }, 58 | 59 | callback: function(err, html) { 60 | if (err) { 61 | reply(err); 62 | } else { 63 | reply(html); 64 | } 65 | } 66 | }); 67 | ``` 68 | 69 | ## Page Lifecyle 70 | 71 | For a given page request cycle a few different stages occur, approximating the browser's life cycle. 72 | 73 | 1. Page created 74 | 1. Initial DOM is loaded 75 | 1. (optional) `beforeExec` callback is run allowing the host to modify the page environment. 76 | 1. Embedded scripts are executed 77 | Scripts are executed sequentially and are blocking regardless of inlined or external. Currently `async` and `defer` attributes are ignored. 78 | 1. (optional) `loaded` callback is executed 79 | 1. Client code continues executing until emit occurs. See [Emit Behaviors](#emit-behaviors) below for details on this behavior. 80 | 81 | ### Emit Behaviors 82 | 83 | Once the page has completed rendering it needs to notify the fruit-loops container that the response is ready for the user. This is done via the `emit` method. 84 | 85 | `emit` supports one of three modes: 86 | 87 | - Immediate: `emit()` 88 | 89 | Outputs the page immediately after this call is made. 90 | 91 | - AJAX completion: `emit('ajax')` 92 | 93 | Outputs the page once all AJAX events have completed. If none are pending at the time this is called emits immediately. 94 | 95 | - Event loop cleared: `emit('events')` 96 | 97 | Outputs the page once all async behaviors have completed. This is a superset of the AJAX completion mode, also waiting for all pending timeouts to complete prior to emitting. This mode is similar to Node's full process life cycle. 98 | 99 | Both the immediate and ajax emit modes will wait for the next node event loop before emitting the page, allowing any pending operations to have a chance to complete. Note that these operations are not guaranteed to complete and critical behaviors generally should not rely on this timeout. 100 | 101 | Note that Fruit loops will cancel pending async behaviors once the page emit's its contents. For ajax calls this means that the request will be aborted at whatever stage they are currently in. For `setTimeout` and `setImmediate` will be cleared by their respective clear API. 102 | 103 | Once the emit process beings, the flow is as follows: 104 | 105 | 1. All callbacks registered through `onEmit` are executed. 106 | 1. All cancellable pending operations are canceled. 107 | 1. (Optional) The `finalize` callback is called 108 | 1. The current request's `callback` is called with the rendered HTML content 109 | 110 | ## Public Only Rendering 111 | 112 | One of the primary goals for Fruit Loops is to enable rendering of public only data. This allows for the server-side tier to handle the SEO concerns and fast load of common content and the client tier can handle augmenting the initial HTML payload with time or user specific data. 113 | 114 | In many situations this architecture allows for the burden of rendering a page to be pushed out to the CDN and client tier rather than forcing the server to handle all pages. 115 | 116 | With this goal in mind Fruit Loops does not currently support features like cookie propagation to the AJAX layer or persistence of the `localStorage` and `sessionStorage` shims. PRs are accepted for this of course. 117 | 118 | ## Security 119 | 120 | Like any other web server framework there are a variety of possible security concerns that might arise within a Fruit Loops environment. Where possible the framework attempts to fail safe but care needs to be taken, particularly when handling user input, to ensure application integrity. 121 | 122 | 123 | ### Sandbox 124 | 125 | All code for a given page is executed within a sandbox which isolates page code from node code. Things such as the host's `require` and other globals are not available to the page unless explicitly exposed through host code such as `beforeExec`. 126 | 127 | Page lifecycle callbacks such as `beforeExec`, `loaded`, etc are not run in the sandbox. 128 | 129 | ### Script Loader 130 | 131 | Fruit Loop's default script loader is intended to be somewhat restrictive to limit risk. To this end it will only automatically load scripts: 132 | 133 | - On the initial page load 134 | - Defined statically within the index's HTML file 135 | - Can be loaded from the file system or from inlined scripts in the HTML 136 | 137 | No attempts will be made to load scripts that are injected at later stages in the page's life cycle. Any such scripts will be executed on the client side so standard XSS protections must be employed to avoid the creation of unauthorized `script` tags. 138 | 139 | Should other scripts be loaded the `loadInContext` utility is available to client code. Even this still has the limitation of requiring that all files be loaded from the local file system. 140 | 141 | ### Dynamic Scripts 142 | 143 | In an effort to reduce possible attack vectors, the ability to execute dynamic code not loaded from the file system is disabled by default. This means that `eval`, `Function()` and `setTimeout(string)` will all explicitly throw if used. Should these behaviors be needed the `evil` flag may be set on the page's options. Enabling this should be done after thorough analysis of the codebase to ensure that there are no cases where arbitrary user input may be executed in an unsafe manner. 144 | 145 | Some libraries, particularly templating libraries, will not operate properly without the evil flag. For Handlebars in particular, the recommendation is that build time precompilation be utilized as this removes the need for dynamic evaluation. 146 | 147 | ### Shared State 148 | 149 | If using the VM pooling functionality then the consequences of an XSS exploit could easily have a much larger impact as attacks can be crafted that will be persistent for the lifetime of the VM. 150 | 151 | ## Supported Features 152 | 153 | Due to differences in the goals of server vs. client rendering, Fruit Loops does not support the following behaviors that might be available within a full browser environment. 154 | 155 | - Most DOM APIs, particularly DOM events 156 | - Layout calculation 157 | - `setInterval` 158 | - Persistent storage 159 | - Cookies 160 | 161 | As such there are some jQuery APIs that are not implemented when running within a fruit-loops context. See [Client APIs](#client-apis) for the complete list of supported APIs. 162 | 163 | There are three different methods generally available for handling the differences between the two tiers. 164 | 165 | 1. Feature detection: Most of the unsupported features are simply not implemented vs. stubed for failure. Any such features can be omitted from the server execution flow simply by using standard feature detection practices. 166 | 1. `$serverSide` global conditional: The global `$serverSide` is set to true on within Fruit Loops page environments and may be used for controlling conditional behavior. It's recommended that these be compiled out using a tool such a Uglify's [conditional compilation](https://github.com/mishoo/UglifyJS2#conditional-compilation) to avoid overhead in one environment or the other. 167 | 1. Server-specific build resolution: If using a tool such as [Lumbar](https://github.com/walmartlabs/lumbar), a server-specific build may be created and loaded via a [`resolver`](#pageoptions) that loads the server specific build. 168 | 169 | It's highly recommended that a framework such as [Thorax](http://thoraxjs.org) be used as this abstracts away many of the differences between the two environments but this is not required. 170 | 171 | ## Performance 172 | 173 | Even though the Fruit Loops strives for an environment with minimal differences between the client and server there are a number of performance concerns that are either specific to the server-side or exacerbated by execution on the server. 174 | 175 | The two biggest performance concerns that have been seen are initialization time and overhead due to rendering otherwise hidden content on the server side. 176 | 177 | ### Initialization Time 178 | 179 | Creating the sandbox and initializing the client SPA infrastructure takes a bit of time and can also lead to confusion for the optimizer. Users that are rendering in a public only system and whose application support safely transitioning between pages via the `navigate` API may want to consider pooling and reusing page instances to avoid unnecessary overhead from repeated operations. 180 | 181 | In one anecdote, an application pooling was able to reduce response times by a factor of 5 due to avoiding the context overhead and recreating the base application logic on each request. The impact of this will vary by application and should be examined in context. 182 | 183 | ### Unnecessary Operations 184 | 185 | Things like rendering menus and other initially hidden content all add to the CPU load necessary for parsing the content. While this is a concern for the client-side rendering as well this is much more noticeable when rendering on the server when all requests share the same event loop. It's recommended that any operations that won't generate meaningful content for the user on the initial load be setup so that the rendering is deferred until the point that it is needed. Generally this optimization should improve the initial load experience for both client and server environments. 186 | 187 | ## Node APIs 188 | 189 | ### `#page(options)` 190 | 191 | Creates a new page object with the given options. 192 | 193 | Available options: 194 | - `index`: Path to the bootstrap file that is used to initialize the page instance 195 | - `callback(err, html, meta)`: Callback called when the page is emitted. Returned data includes: 196 | - `html`: HTML content of the page at emit time 197 | - `meta`: Metadata regarding the rendering cycle. Includes: 198 | - `status`: HTTP Status code for the response 199 | - `cache`: Minimum cache values of all components used in the response. 200 | - `taskLog`: List of tasks such as AJAX requests made along with basic response and duration info. 201 | - `incompleteTasks`: Number of pending operations that were running at the time of emit. 202 | - `maxTasks`: Maximum number of concurrent tasks running at any given time for the response. 203 | - `beforeExec(page, next)`: Optional callback called after the DOM has loaded, but prior to any scripts executing. Must call `next` once complete to continue page execution. 204 | - `loaded(page)`: Optional callback called after the DOM and all scripts have been loaded 205 | - `finalize(page)`: Optional callback called just prior to the callback method being called. 206 | - `resolver(href, page)`: Callback used to resolve the file path external resources needed to render the page. The default behavior is to lookup resources relative to the `index` file. 207 | - `host`: Host name that will be passed to the page's context. 208 | - `protocol`: Used to generate the `window.location` object. Defaults to `http:` 209 | - `path`: Path of the page, including any query or hash information. The should be relative to the host's root. 210 | - `userAgent`: Use agent value used to seed the `window.navigator.userAgent` value. 211 | - `cacheResources`: Truthy to cache script and page resources within the javascript heap. When this is enabled, no attempt will be made to reload content from disk after it's initially loaded/parsed. 212 | - `ajax`: Object defining ajax request options 213 | - `shortCircuit(options, callback)`: Optional method that may be used to provide alternative processing for the AJAX request. Should return truthy if the method can process the request and should preempt the normal AJAX request processing. This may be used with utilities like Hapi's `server.inject` to optimize local requests, for example. `callback` expects `callback(err, response, cache, report)` where `response` is an object with fields `{statusCode, data}`. `cache` and `report` are optional reporting parameters that match the Catbox return data. 214 | - `cache`: Optional [Catbox Policy](https://github.com/spumko/catbox#policy) instance used to cache AJAX responses used to generate the page. All responses will be cached per the HTTP cache headers returned. 215 | - `timeout`: Default timeout for AJAX requests. When client calls specify a timeout and this value is specified, the lower of the two values will be used as the effective timeout for the call. Defaults to no timeout. 216 | - `evil`: Truthy to enable dynamic code execution via `eval`, `Function`, and `setTimeout`. See [dynamic scripts](#dynamic-scripts) for more information. 217 | - `metadata`: Metadata that is assigned to the page instance and may be used within callbacks. 218 | 219 | The returned page instance consists of: 220 | - `id`: Unique id value that may be used to identify the page 221 | - `window`: The page's global object 222 | - `$`: The page's internal `$` API. Note that this is not the same object as `window.$` as it exposes internal interfaces. 223 | - `exec`: Utility method used to safely execute client code 224 | - `emit`: Alias for `window.emit` 225 | - `dispose()`: Should be called after a page is no longer needed in order to clean up resources. 226 | - `navigate(path, callback)`: Updates the existing page to point to a new path. This will clear a variety of the page's state and should only be done for pages that expect this behavior. See the [performance](#performance) section for further discussion. 227 | 228 | ### `#pool(options)` 229 | 230 | Creates a pool of page objects. 231 | 232 | Shares the same options as the `page` method with a few distinctions: 233 | - `path` and `callback` will be ignored. The values passed to `navigate` will be used instead. 234 | - Adds the `poolSize` option used to specify the number of pages to create at once. 235 | - Adds the `maxQueue` option used to limit the number of requests that can be queued at a given time. If set a `EQUEUEFULL` error will be returned if this limit is exceeded. 236 | - Add the `queueTimeout` option used to timeout requests that pending in the queue. If triggered this will return a `EQUEUETIMEOUT` error. 237 | - Adds `navigated(page, existingPage)` callback which is called after a page is reused. This should be used to notify the application that the path has changed, i.e. `Backbone.history.loadUrl()` or similar. Will be called for all `pool.navigated` calls. `existingPage` will be true when the page has been used in a previous render cycle. 238 | - When `cacheResources` is falsy a `fs.watch` will be performed on all script files loaded into the pool. Should one change then the pool will be restarted. This will preempt any running requests, leaving them in an indeterminate state. In production it's recommended that this flag be set to `true`. 239 | 240 | The returned pool instance consists of: 241 | - `navigate(path [, metadata], callback)`: Renders a given path and returns it to callback. Optional `metadata` argument may be passed to override the initial metadata for a given page. 242 | - `dispose()`: Should be called after a pool is no longer needed in order to clean up resources. 243 | 244 | ```javascript 245 | var pool = FruitLoops.pool({ 246 | poolSize: 2, 247 | index: __dirname + '/artifacts/pool-page.html', 248 | navigated: function(page, existingPage) { 249 | if (existingPage) { 250 | // Force backbone navigation if the page has been previously used. 251 | page.window.Backbone.history.loadUrl(); 252 | } 253 | } 254 | }); 255 | pool.navigate('/bar', function(err, html) { 256 | if (err) { 257 | reply(err); 258 | } else { 259 | reply(html); 260 | } 261 | }); 262 | ``` 263 | 264 | ### `page.$.ajax` 265 | 266 | There are a number of utility methods exposed on the node-side ajax instance including: 267 | 268 | - `.allComplete` Returns `true` if there are no requests currently waiting. 269 | - `.toJSON` Returns a stringified JSON object containing the response content from all requests involved in the page. 270 | - `.minimumCache` Returns a structure containing the minimum cache of all requests. Contains 271 | - `no-cache`: Truthy if the response is not cacheable 272 | - `private`: Truthy if the response must be private cached 273 | - `expires`: Number of seconds that the response should expire in. `Number.MAX_VALUE` if no content contained a cache expiration. 274 | 275 | ## Client APIs 276 | 277 | ### $ APIs 278 | 279 | The following APIs are supported and should match the [jQuery](http://api.jquery.com/)/[Zepto](http://zeptojs.com/) implementation unless otherwise noted. 280 | 281 | #### Constructors 282 | 283 | - `$(selector)` 284 | - `$(fruit loops collection)` 285 | - `$(function() {})` / `.ready(function() {})` 286 | 287 | #### Tree Traversal 288 | 289 | - `.find` 290 | - `.parent` 291 | - `.parents` 292 | - `.closest` 293 | - `.next` 294 | - `.nextAll` 295 | - `.nextUntil` 296 | - `.prev` 297 | - `.prevAll` 298 | - `.prevUntil` 299 | - `.siblings` 300 | - `.children` 301 | - `.contents` 302 | 303 | # Set Handling 304 | 305 | - `.each` 306 | - `.forEach` 307 | - `.map` 308 | - `.filter` 309 | - `.first` 310 | - `.last` 311 | - `.eq` 312 | - `.get` 313 | - `.slice` 314 | - `.end` 315 | - `.toArray` 316 | - `.pluck` 317 | 318 | #### Tree Manipulation 319 | 320 | - `.append` 321 | - `.appendTo` 322 | - `.prepend` 323 | - `.prependTo` 324 | - `.after` 325 | - `.insertAfter` 326 | - `.before` 327 | - `.insertBefore` 328 | - `.detach` 329 | - `.remove` 330 | - `.replaceWith` 331 | - `.replaceAll` 332 | - `.empty` 333 | - `.html` 334 | - `.text` 335 | - `.clone` 336 | 337 | #### Node Manipulation 338 | 339 | - `.attr` 340 | - `.data` 341 | - `.val` 342 | - `.removeAttr` 343 | - `.hasClass` 344 | - `.addClass` 345 | - `.removeClass` 346 | - `.toggleClass` 347 | - `.is` 348 | - `.css` 349 | - `.toggle` 350 | - `.show` 351 | - `.hide` 352 | - `.focus` - Sets the `autofocus` attribute 353 | - `.blur` - Unsets the `autofocus` attribute 354 | 355 | Not implemented: 356 | 357 | - `.height` 358 | - `.innerHeight` 359 | - `.innerWidth` 360 | - `.offset` 361 | - `.offsetParent` 362 | - `.outerHeight` 363 | - `.outerWidth` 364 | - `.position` 365 | - `.scrollLeft` 366 | - `.scrollTop` 367 | - `.width` 368 | - `.prop` 369 | - `.removeProp` 370 | 371 | #### Event APIs 372 | 373 | Fruit loops implements stubs for: 374 | 375 | - `.bind` 376 | - `.unbind` 377 | - `.on` 378 | - `.off` 379 | - `.live` 380 | - `.die` 381 | - `.delegate` 382 | - `.undelegate` 383 | - `.one` 384 | 385 | Each of the above methods will perform no operations but may be chained. 386 | 387 | Methods designed to trigger events are explicitly not implemented. 388 | 389 | - `.change` 390 | - `.click` 391 | - `.dblclick` 392 | - `.error` 393 | - `.focusin` 394 | - `.focusout` 395 | - `.hover` 396 | - `.keydown` 397 | - `.keypress` 398 | - `.keyup` 399 | - `.mousedown` 400 | - `.mouseenter` 401 | - `.mouseleave` 402 | - `.mousemove` 403 | - `.mouseout` 404 | - `.mouseover` 405 | - `.mouseup` 406 | - `.resize` 407 | - `.scroll` 408 | - `.select` 409 | - `.trigger` 410 | - `.triggerHandler` 411 | - `.submit` 412 | - `.unload` 413 | 414 | #### Detect 415 | 416 | Fruit loop implements a direct port of Zepto's `$.detect` library. 417 | 418 | #### AJAX 419 | 420 | - `$.ajax` 421 | - `$.param` 422 | 423 | Not currently supported: 424 | - `$.ajaxJSONP` 425 | - `$.ajaxSettings` 426 | - `$.get` 427 | - `$.getJSON` 428 | - `$.post` 429 | - `.load` 430 | 431 | #### Form 432 | 433 | Form handling methods are not supported at this time. This includes: 434 | 435 | - `.serialize` 436 | - `.serializeArray` 437 | - `.submit` 438 | 439 | #### Effects 440 | 441 | Effects APIs are generally not support in fruit loops. The exception being: 442 | 443 | - `.animate` - Implements immediate set operation 444 | 445 | #### Static Methods 446 | 447 | - `$.contains` 448 | - `$.each` 449 | - `$.extend` 450 | - `$.globalEval` 451 | - `$.grep` 452 | - `$.inArray` 453 | - `$.isArray` 454 | - `$.isFunction` 455 | - `$.isNumeric` 456 | - `$.isEmptyObject` 457 | - `$.isPlainObject` 458 | - `$.isWindow` 459 | - `$.makeArray` 460 | - `$.map` 461 | - `$.merge` 462 | - `$.noop` 463 | - `$.now` 464 | - `$.parseHTML` 465 | - `$.proxy` 466 | - `$.trim` 467 | - `$.type` 468 | 469 | Not implement: 470 | - `$.getScript` 471 | - `$.isXMLDoc` 472 | - `$.parseXML` 473 | 474 | ### DOM APIs 475 | 476 | In addition to the `$` APIs, Fruit Loops implements a variety of DOM and global browser APIs. 477 | 478 | - `console` 479 | 480 | Outputs to the process's console. 481 | 482 | - `setTimeout` 483 | 484 | The responses from these methods are generally `$` instances rather than true DOM objects. Code that is expecting true DOM objects will need to be updated to account for this or otherwise utilize the `$` APIs. 485 | 486 | - `history` 487 | - `pushState` 488 | - `replaceState` 489 | 490 | Note that both of these APIs perform a generic redirect and will terminate pending operations on the page. 491 | 492 | - `location` 493 | - `navigator` 494 | - `userAgent` 495 | - `performance` 496 | - `timing` 497 | - `localStorage`/`sessionStorage` 498 | 499 | Transient storage for the duration of the page's life cycle. This is not persisted in any way. 500 | 501 | ### Fruit Loops Extensions 502 | 503 | - `$serverSide` 504 | 505 | Constant flag. Set to `true`, allowing client code to differentiate between client and server contexts. 506 | 507 | - `FruitLoops.emit(action)` 508 | 509 | Begins the page output process. See [emit behaviors](#emit-behaviors) for more details. 510 | 511 | - `FruitLoops.loadInContext(href, callback)` 512 | 513 | Loads a given script. `href` should be a relative client script path. The `resolver` callback may be be used to remap this if needed. Upon completion `callback()` will be called. 514 | 515 | - `FruitLoops.statusCode(code)` 516 | 517 | Sets the desired status code for the response. This does not terminate further page execution. 518 | 519 | - `FruitLoops.redirect(url)` 520 | 521 | Stops further execution and emits a redirect to `url` as the page's response. 522 | 523 | - `setImmediate(callback)` 524 | 525 | Exposes node's `setImmediate` API. Allows for more performant timeout calls vs. `setTimeout` without a timeout. 526 | 527 | - `nextTick(callback)` 528 | 529 | Exposes node's `nextTick` API. `setImmediate` is preferred in most case as `nextTick` can lead to IO starvation. 530 | 531 | -------------------------------------------------------------------------------- /test/page.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | fruitLoops = require('../lib'), 3 | ajax = require('../lib/jquery/ajax'), 4 | fs = require('fs'), 5 | hapi = require('hapi'), 6 | sinon = require('sinon'); 7 | 8 | describe('page', function() { 9 | var server, 10 | page; 11 | before(function(done) { 12 | server = new hapi.Server(0); 13 | server.route({ 14 | path: '/', 15 | method: 'GET', 16 | config: {jsonp: 'callback'}, 17 | handler: function(req, reply) { 18 | setTimeout(function() { 19 | reply({data: 'get!'}); 20 | }, 10); 21 | } 22 | }); 23 | server.route({ 24 | path: '/script', 25 | method: 'GET', 26 | handler: function(req, reply) { 27 | reply({data: ''}); 28 | } 29 | }); 30 | server.start(done); 31 | }); 32 | after(function(done) { 33 | server.stop(done); 34 | }); 35 | afterEach(function() { 36 | page.dispose(); 37 | page = undefined; 38 | }); 39 | 40 | it('should load html source', function(done) { 41 | page = fruitLoops.page({ 42 | userAgent: 'anything but android', 43 | url: { 44 | path: '/foo' 45 | }, 46 | index: __dirname + '/artifacts/empty-page.html', 47 | loaded: function(page) { 48 | page.window.$.should.exist; 49 | page.window.$serverSide.should.be.true; 50 | done(); 51 | } 52 | }); 53 | }); 54 | it('should handle page load errors', function(done) { 55 | page = fruitLoops.page({ 56 | userAgent: 'anything but android', 57 | url: { 58 | path: '/foo' 59 | }, 60 | index: __dirname + '/artifacts/does-not-exist.html', 61 | callback: function(err) { 62 | err.should.be.instanceOf(Error); 63 | done(); 64 | } 65 | }); 66 | }); 67 | it('should callback before script init', function(done) { 68 | var execCalled; 69 | page = fruitLoops.page({ 70 | userAgent: 'anything but android', 71 | url: { 72 | path: '/foo' 73 | }, 74 | index: __dirname + '/artifacts/script-page.html', 75 | beforeExec: function(page, next) { 76 | should.exist(page); 77 | execCalled = true; 78 | next(); 79 | }, 80 | loaded: function() { 81 | execCalled.should.be.true; 82 | page.window.inlinedVar.should.equal(1); 83 | page.window.externalVar.should.equal(2); 84 | page.window.syncVar.should.equal(3); 85 | done(); 86 | } 87 | }); 88 | }); 89 | it('should handle resolver error', function(done) { 90 | var execCalled; 91 | page = fruitLoops.page({ 92 | userAgent: 'anything but android', 93 | url: { 94 | path: '/foo' 95 | }, 96 | index: __dirname + '/artifacts/script-page.html', 97 | resolver: function(href, page) { 98 | execCalled = true; 99 | throw new Error('failed'); 100 | }, 101 | callback: function(err) { 102 | err.should.be.instanceOf(Error); 103 | err.message.should.equal('failed'); 104 | done(); 105 | } 106 | }); 107 | }); 108 | it('should handle before script error', function(done) { 109 | var execCalled; 110 | page = fruitLoops.page({ 111 | userAgent: 'anything but android', 112 | url: { 113 | path: '/foo' 114 | }, 115 | index: __dirname + '/artifacts/script-page.html', 116 | beforeExec: function(page, next) { 117 | execCalled = true; 118 | next(new Error('failed')); 119 | }, 120 | callback: function(err) { 121 | err.should.be.instanceOf(Error); 122 | err.message.should.equal('failed'); 123 | done(); 124 | } 125 | }); 126 | }); 127 | it('should load all inlined scripts', function(done) { 128 | page = fruitLoops.page({ 129 | userAgent: 'anything but android', 130 | url: { 131 | path: '/foo' 132 | }, 133 | index: __dirname + '/artifacts/script-page.html', 134 | loaded: function() { 135 | page.window.inlinedVar.should.equal(1); 136 | page.window.externalVar.should.equal(2); 137 | page.window.syncVar.should.equal(3); 138 | done(); 139 | } 140 | }); 141 | }); 142 | it('should allow custom file resolution', function(done) { 143 | var resolver = this.spy(function() { 144 | return __dirname + '/artifacts/other-script.js'; 145 | }); 146 | 147 | page = fruitLoops.page({ 148 | userAgent: 'anything but android', 149 | url: '/foo', 150 | index: __dirname + '/artifacts/script-page.html', 151 | resolver: resolver, 152 | loaded: function() { 153 | resolver.should 154 | .have.been.calledOnce 155 | .have.been.calledWith('/test-script.js', page); 156 | 157 | page.window.inlinedVar.should.equal(1); 158 | page.window.externalVar.should.equal(3); 159 | page.window.syncVar.should.equal(4); 160 | done(); 161 | } 162 | }); 163 | }); 164 | it('should error on missing scripts', function(done) { 165 | var callback = this.spy(); 166 | 167 | page = fruitLoops.page({ 168 | userAgent: 'anything but android', 169 | url: { 170 | path: '/foo' 171 | }, 172 | index: __dirname + '/artifacts/script-page.html', 173 | resolver: function() { 174 | return __dirname + '/artifacts/not-a-script.js'; 175 | }, 176 | callback: function(err) { 177 | err.should.be.instanceOf(Error); 178 | done(); 179 | } 180 | }); 181 | }); 182 | it('should error on external syntax error', function(done) { 183 | var callback = this.spy(); 184 | 185 | page = fruitLoops.page({ 186 | userAgent: 'anything but android', 187 | url: { 188 | path: '/foo' 189 | }, 190 | index: __dirname + '/artifacts/script-page.html', 191 | resolver: function() { 192 | return __dirname + '/artifacts/syntax-error.js'; 193 | }, 194 | callback: function(err) { 195 | err.should.be.instanceOf(Error); 196 | done(); 197 | } 198 | }); 199 | }); 200 | 201 | it('should prevent exec on redirect in external script', function(done) { 202 | this.clock.restore(); 203 | 204 | page = fruitLoops.page({ 205 | userAgent: 'anything but android', 206 | index: __dirname + '/artifacts/script-page.html', 207 | resolver: function() { 208 | return __dirname + '/artifacts/redirect-script.js'; 209 | }, 210 | callback: function(err, data) { 211 | should.not.exist(err); 212 | data.should.eql({redirect: '/foo'}); 213 | 214 | setTimeout(done, 100); 215 | } 216 | }); 217 | }); 218 | 219 | it('should prevent exec on error in external script', function(done) { 220 | this.clock.restore(); 221 | 222 | page = fruitLoops.page({ 223 | userAgent: 'anything but android', 224 | index: __dirname + '/artifacts/script-page.html', 225 | resolver: function() { 226 | return __dirname + '/artifacts/error-script.js'; 227 | }, 228 | callback: function(err) { 229 | err.should.be.instanceOf(Error); 230 | err.toString().should.match(/error-script expected/); 231 | 232 | setTimeout(done, 100); 233 | } 234 | }); 235 | }); 236 | it('should prevent exec on error in internal script', function(done) { 237 | this.clock.restore(); 238 | 239 | page = fruitLoops.page({ 240 | userAgent: 'anything but android', 241 | index: __dirname + '/artifacts/inline-script-error.html', 242 | callback: function(err) { 243 | err.should.be.instanceOf(Error); 244 | err.toString().should.match(/Expected/); 245 | 246 | setTimeout(done, 100); 247 | } 248 | }); 249 | }); 250 | it('should prevent exec on error in internal script', function(done) { 251 | this.clock.restore(); 252 | 253 | page = fruitLoops.page({ 254 | userAgent: 'anything but android', 255 | index: __dirname + '/artifacts/inline-script-syntax-error.html', 256 | callback: function(err) { 257 | err.should.be.instanceOf(SyntaxError); 258 | err.stack.should.match(/SyntaxError: Unexpected token \]\n\nInline Script:\n\t\n\n'); 304 | done(); 305 | } 306 | }); 307 | }); 308 | 309 | 310 | // Ensures that we have this fix: 311 | // https://github.com/MatthewMueller/cheerio/pull/350 312 | it('should move scripts to the end of the body', function(done) { 313 | var finalize = this.spy(); 314 | 315 | page = fruitLoops.page({ 316 | userAgent: 'anything but android', 317 | path: '/foo', 318 | index: __dirname + '/artifacts/script-page.html', 319 | finalize: finalize, 320 | loaded: function(page) { 321 | page.window.emit(); 322 | setTimeout.clock.tick(1000); 323 | }, 324 | callback: function(err, html) { 325 | finalize.should.have.been.calledOnce; 326 | finalize.should.have.been.calledWith(page); 327 | 328 | should.not.exist(err); 329 | html.should.equal('\n' 330 | + '\n' 331 | + ' \n \n \n \n' 332 | + ' ' 333 | + '' 334 | + '' 335 | + '' 336 | + '\n\n'); 337 | done(); 338 | } 339 | }); 340 | }); 341 | 342 | describe('serverCache', function() { 343 | it('should output serverCache with escaped content', function(done) { 344 | this.clock.restore(); 345 | 346 | page = fruitLoops.page({ 347 | userAgent: 'anything but android', 348 | url: { 349 | path: '/foo' 350 | }, 351 | index: __dirname + '/artifacts/empty-page.html', 352 | loaded: function(page) { 353 | page.window.$.ajax({ 354 | url: 'http://localhost:' + server.info.port + '/script' 355 | }); 356 | page.window.emit('events'); 357 | }, 358 | callback: function(err, html) { 359 | should.not.exist(err); 360 | html.should.equal('\n\n foo\n\n'); 361 | done(); 362 | } 363 | }); 364 | }); 365 | }); 366 | 367 | describe('ajax completion', function() { 368 | it('should allow emit on AJAX completion', function(done) { 369 | var test = this, 370 | finalize = test.spy(), 371 | allComplete = false, 372 | callback = false; 373 | 374 | page = fruitLoops.page({ 375 | userAgent: 'anything but android', 376 | url: { 377 | path: '/foo' 378 | }, 379 | index: __dirname + '/artifacts/empty-page.html', 380 | finalize: finalize, 381 | loaded: function(page) { 382 | test.stub(page.$.ajax, 'allComplete', function() { return allComplete; }); 383 | 384 | page.window.emit('ajax'); 385 | setTimeout.clock.tick(1000); 386 | callback.should.be.false; 387 | 388 | allComplete = true; 389 | page.$.ajax.emit('complete'); 390 | setTimeout.clock.tick(1000); 391 | }, 392 | callback: function(err, html) { 393 | callback = true; 394 | allComplete.should.be.true; 395 | finalize.should.have.been.calledOnce; 396 | finalize.should.have.been.calledWith(page); 397 | 398 | should.not.exist(err); 399 | html.should.equal('\n\n foo\n\n'); 400 | done(); 401 | } 402 | }); 403 | }); 404 | it('should emit if nothing pending', function(done) { 405 | var test = this, 406 | callback = false; 407 | 408 | page = fruitLoops.page({ 409 | userAgent: 'anything but android', 410 | url: { 411 | path: '/foo' 412 | }, 413 | index: __dirname + '/artifacts/empty-page.html', 414 | loaded: function(page) { 415 | callback.should.be.false; 416 | page.window.emit('ajax'); 417 | }, 418 | callback: function(err, html, meta) { 419 | callback = true; 420 | 421 | should.not.exist(err); 422 | html.should.equal('\n\n foo\n\n'); 423 | meta.taskLog.length.should.eql(1); 424 | meta.incompleteTasks.should.equal(0); 425 | meta.maxTasks.should.equal(1); 426 | done(); 427 | } 428 | }); 429 | }); 430 | }); 431 | 432 | describe('event completion', function() { 433 | it('should emit after all ajax and timeouts', function(done) { 434 | this.clock.restore(); 435 | 436 | var timeoutSpy = this.spy(), 437 | ajaxSpy = this.spy(), 438 | callback = false; 439 | 440 | page = fruitLoops.page({ 441 | userAgent: 'anything but android', 442 | url: { 443 | path: '/foo' 444 | }, 445 | index: __dirname + '/artifacts/empty-page.html', 446 | loaded: function(page) { 447 | callback.should.be.false; 448 | 449 | page.window.setTimeout(timeoutSpy, 25); 450 | page.window.emit('events'); 451 | page.window.$.ajax({ 452 | url: 'http://localhost:' + server.info.port + '/', 453 | complete: function() { 454 | page.window.setTimeout(timeoutSpy, 10); 455 | ajaxSpy(); 456 | } 457 | }); 458 | }, 459 | callback: function(err, html, meta) { 460 | callback = true; 461 | 462 | timeoutSpy.should.have.been.calledTwice; 463 | ajaxSpy.should.have.been.calledOnce; 464 | 465 | meta.taskLog.length.should.eql(4); 466 | _.pluck(meta.taskLog, 'type').should.eql(['beforeExec', 'timeout', 'ajax', 'timeout']); 467 | _.pluck(meta.taskLog, 'id').should.eql([1, 0, 0, 1]); 468 | meta.taskLog[2].should.have.properties({ 469 | type: 'ajax', 470 | id: 0, 471 | url: 'http://localhost:' + server.info.port + '/', 472 | statusCode: 200, 473 | status: 'success', 474 | cached: false 475 | }); 476 | 477 | should.not.exist(err); 478 | html.should.equal('\n\n foo\n\n'); 479 | done(); 480 | } 481 | }); 482 | }); 483 | it('should wait until end of event loop to determine emit completion', function(done) { 484 | this.clock.restore(); 485 | 486 | var timeoutSpy = this.spy(), 487 | ajaxSpy = this.spy(), 488 | callback = false; 489 | 490 | page = fruitLoops.page({ 491 | userAgent: 'anything but android', 492 | url: { 493 | path: '/foo' 494 | }, 495 | index: __dirname + '/artifacts/empty-page.html', 496 | loaded: function(page) { 497 | callback.should.be.false; 498 | 499 | page.window.emit('events'); 500 | page.window.setTimeout(timeoutSpy, 25); 501 | page.window.$.ajax({ 502 | url: 'http://localhost:' + server.info.port + '/', 503 | complete: function() { 504 | page.window.setTimeout(timeoutSpy, 10); 505 | ajaxSpy(); 506 | } 507 | }); 508 | }, 509 | callback: function(err, html, meta) { 510 | callback = true; 511 | 512 | timeoutSpy.should.have.been.calledTwice; 513 | ajaxSpy.should.have.been.calledOnce; 514 | 515 | meta.taskLog.length.should.eql(4); 516 | _.pluck(meta.taskLog, 'type').should.eql(['beforeExec', 'timeout', 'ajax', 'timeout']); 517 | _.pluck(meta.taskLog, 'id').should.eql([1, 0, 0, 1]); 518 | meta.taskLog[2].should.have.properties({ 519 | type: 'ajax', 520 | id: 0, 521 | url: 'http://localhost:' + server.info.port + '/', 522 | statusCode: 200, 523 | status: 'success', 524 | cached: false 525 | }); 526 | 527 | should.not.exist(err); 528 | html.should.equal('\n\n foo\n\n'); 529 | done(); 530 | } 531 | }); 532 | }); 533 | it('should emit events if no events pending', function(done) { 534 | var callback = false; 535 | 536 | page = fruitLoops.page({ 537 | userAgent: 'anything but android', 538 | url: { 539 | path: '/foo' 540 | }, 541 | index: __dirname + '/artifacts/empty-page.html', 542 | loaded: function(page) { 543 | callback.should.be.false; 544 | page.window.emit('events'); 545 | }, 546 | callback: function(err, html) { 547 | callback = true; 548 | 549 | should.not.exist(err); 550 | html.should.equal('\n\n foo\n\n'); 551 | done(); 552 | } 553 | }); 554 | }); 555 | }); 556 | 557 | describe('terminate pending', function() { 558 | it('on emit', function(done) { 559 | this.clock.restore(); 560 | 561 | function fail() { 562 | throw new Error('This was called'); 563 | } 564 | 565 | page = fruitLoops.page({ 566 | userAgent: 'anything but android', 567 | path: '/foo', 568 | index: __dirname + '/artifacts/empty-page.html', 569 | loaded: function(page) { 570 | page.window.setTimeout(fail, 10); 571 | page.window.$.ajax({ 572 | url: 'http://localhost:' + server.info.port + '/', 573 | error: fail, 574 | complete: fail 575 | }); 576 | page.window.emit(); 577 | }, 578 | callback: function(err, html, meta) { 579 | meta.incompleteTasks.should.equal(2); 580 | meta.maxTasks.should.equal(2); 581 | 582 | setTimeout(function() { 583 | done(); 584 | }, 100); 585 | } 586 | }); 587 | }); 588 | it('on redirect', function(done) { 589 | this.clock.restore(); 590 | 591 | function fail() { 592 | throw new Error('This was called'); 593 | } 594 | 595 | page = fruitLoops.page({ 596 | userAgent: 'anything but android', 597 | path: '/foo', 598 | index: __dirname + '/artifacts/empty-page.html', 599 | loaded: function(page) { 600 | page.window.setTimeout(fail, 10); 601 | page.window.$.ajax({ 602 | url: 'http://localhost:' + server.info.port + '/', 603 | error: fail, 604 | complete: fail 605 | }); 606 | page.window.location.assign('/bar'); 607 | }, 608 | callback: function(err, html, meta) { 609 | html.should.eql({redirect: '/bar'}); 610 | meta.incompleteTasks.should.equal(2); 611 | meta.maxTasks.should.equal(2); 612 | 613 | setTimeout(function() { 614 | done(); 615 | }, 100); 616 | } 617 | }); 618 | }); 619 | it('on error', function(done) { 620 | this.clock.restore(); 621 | 622 | function fail() { 623 | throw new Error('This was called'); 624 | } 625 | 626 | page = fruitLoops.page({ 627 | userAgent: 'anything but android', 628 | path: '/foo', 629 | index: __dirname + '/artifacts/empty-page.html', 630 | loaded: function(page) { 631 | page.window.setTimeout(fail, 10); 632 | page.window.$.ajax({ 633 | url: 'http://localhost:' + server.info.port + '/', 634 | error: fail, 635 | complete: fail 636 | }); 637 | page.exec(function() { 638 | throw new Error('You fail'); 639 | }); 640 | }, 641 | callback: function(err, html, meta) { 642 | err.toString().should.match(/You fail/); 643 | meta.incompleteTasks.should.equal(2); 644 | meta.maxTasks.should.equal(2); 645 | 646 | setTimeout(function() { 647 | done(); 648 | }, 100); 649 | } 650 | }); 651 | }); 652 | 653 | it('on error in loader', function(done) { 654 | this.clock.restore(); 655 | 656 | function fail() { 657 | throw new Error('This was called'); 658 | } 659 | 660 | page = fruitLoops.page({ 661 | userAgent: 'anything but android', 662 | path: '/foo', 663 | index: __dirname + '/artifacts/script-page.html', 664 | resolver: function() { 665 | return __dirname + '/artifacts/not-found.js'; 666 | }, 667 | beforeExec: function(page, next) { 668 | page.window.setTimeout(fail, 10); 669 | page.window.$.ajax({ 670 | url: 'http://localhost:' + server.info.port + '/', 671 | error: fail, 672 | complete: fail 673 | }); 674 | next(); 675 | }, 676 | callback: function(err, html) { 677 | err.toString().should.match(/ENOENT/); 678 | setTimeout(function() { 679 | done(); 680 | }, 100); 681 | } 682 | }); 683 | }); 684 | }); 685 | 686 | it('should output on multiple emits', function(done) { 687 | page = fruitLoops.page({ 688 | userAgent: 'anything but android', 689 | path: '/foo', 690 | index: __dirname + '/artifacts/empty-page.html', 691 | loaded: function(page) { 692 | page.window.emit('events'); 693 | page.window.emit(); 694 | }, 695 | callback: function(err, html, meta) { 696 | should.not.exist(err); 697 | html.should.equal('\n\n foo\n\n'); 698 | meta.incompleteTasks.should.equal(0); 699 | meta.maxTasks.should.equal(1); 700 | done(); 701 | } 702 | }); 703 | }); 704 | 705 | it('should fail on multiple after complete', function(done) { 706 | page = fruitLoops.page({ 707 | userAgent: 'anything but android', 708 | path: '/foo', 709 | index: __dirname + '/artifacts/empty-page.html', 710 | loaded: function(page) { 711 | page.window.emit(); 712 | }, 713 | callback: function(err, html) { 714 | setImmediate(function() { 715 | should.throw(function() { 716 | page.emit(); 717 | }, Error, /Emit outside of request:.*/); 718 | 719 | done(); 720 | }); 721 | } 722 | }); 723 | }); 724 | 725 | it('should fail on action after complete', function(done) { 726 | page = fruitLoops.page({ 727 | userAgent: 'anything but android', 728 | path: '/foo', 729 | index: __dirname + '/artifacts/empty-page.html', 730 | loaded: function(page) { 731 | page.window.emit(); 732 | }, 733 | callback: function(err, html) { 734 | setImmediate(function() { 735 | should.throw(function() { 736 | page.window.location.assign('foo'); 737 | }, Error, /Emit outside of request:.*/); 738 | 739 | done(); 740 | }); 741 | } 742 | }); 743 | }); 744 | 745 | it('should support multiple emits with navigate', function(done) { 746 | var finalize = this.spy(), 747 | firstEmitSeen; 748 | 749 | page = fruitLoops.page({ 750 | userAgent: 'anything but android', 751 | path: '/foo', 752 | index: __dirname + '/artifacts/empty-page.html', 753 | finalize: finalize, 754 | loaded: function(page) { 755 | page.window.emit('events'); 756 | setTimeout.clock.tick(1000); 757 | }, 758 | callback: function(err, html) { 759 | should.not.exist(err); 760 | html.should.equal('\n\n foo\n\n'); 761 | 762 | page.navigate('/bar', function(err, html) { 763 | finalize.should.have.been.calledTwice; 764 | 765 | should.not.exist(err); 766 | html.should.equal('\n\n foo\n\n'); 767 | 768 | done(); 769 | }); 770 | 771 | page.window.location.toString().should.equal('http://localhost/bar'); 772 | page.emit(); 773 | setTimeout.clock.tick(1000); 774 | } 775 | }); 776 | }); 777 | 778 | it('should call onEmit callbacks', function(done) { 779 | var spy = this.spy(); 780 | 781 | page = fruitLoops.page({ 782 | userAgent: 'anything but android', 783 | path: '/foo', 784 | index: __dirname + '/artifacts/empty-page.html', 785 | loaded: function(page) { 786 | page.window.onEmit(spy); 787 | page.window.emit(); 788 | setTimeout.clock.tick(1000); 789 | }, 790 | callback: function(err, html) { 791 | spy.should.have.been.calledOnce; 792 | done(); 793 | } 794 | }); 795 | }); 796 | }); 797 | 798 | it('should cache scripts', function(done) { 799 | var resolver = this.spy(function() { 800 | return __dirname + '/artifacts/other-script.js'; 801 | }); 802 | this.spy(fs, 'readFile'); 803 | 804 | function exec(loaded) { 805 | page = fruitLoops.page({ 806 | userAgent: 'anything but android', 807 | cacheResources: true, 808 | path: '/foo', 809 | index: __dirname + '/artifacts/script-page.html', 810 | resolver: resolver, 811 | loaded: function() { 812 | resolver.should 813 | .have.been.calledWith('/test-script.js', page); 814 | 815 | page.window.inlinedVar.should.equal(1); 816 | page.window.externalVar.should.equal(3); 817 | page.window.syncVar.should.equal(4); 818 | loaded(); 819 | } 820 | }); 821 | } 822 | 823 | exec(function() { 824 | resolver.should.have.been.calledOnce; 825 | fs.readFile.should.have.been.calledTwice; 826 | 827 | exec(function() { 828 | resolver.should.have.been.calledTwice; 829 | fs.readFile.should.have.been.calledTwice; 830 | 831 | done(); 832 | }); 833 | }); 834 | }); 835 | }); 836 | --------------------------------------------------------------------------------