diff --git a/.gitignore b/.gitignore index 823b3d1..7582006 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ coverage/ +coverage.json tmp/ npm-debug.log* .DS_Store diff --git a/.travis.yml b/.travis.yml index c1e0e35..99ea9e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,13 @@ node_js: - '6' sudo: false language: node_js +addons: + apt: + packages: + - xvfb +before_install: + - export DISPLAY=':99.0' + - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & script: npm run test:cov after_script: npm i -g codecov.io && cat ./coverage/lcov.info | codecov env: diff --git a/index.js b/index.js index 872e066..f739f04 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ const history = require('sheet-router/history') const sheetRouter = require('sheet-router') const document = require('global/document') +const onReady = require('document-ready') const href = require('sheet-router/href') const hash = require('sheet-router/hash') const hashMatch = require('hash-match') @@ -40,7 +41,7 @@ function choo (opts) { const state = _store.state({ state: serverState }) const router = createRouter(_defaultRoute, _routes, createSend) const tree = router(route, state) - return tree.toString() + return tree.outerHTML || tree.toString() function createSend () { return function send () { @@ -61,14 +62,14 @@ function choo (opts) { _store.model(appInit(startOpts)) const createSend = _store.start(startOpts) _router = start._router = createRouter(_defaultRoute, _routes, createSend) - const state = _store.state() + const state = _store.state({state: {}}) if (!selector) { const tree = _router(state.location.pathname, state) _rootNode = tree return tree } else { - document.addEventListener('DOMContentLoaded', function (event) { + onReady(function onReady () { const oldTree = document.querySelector(selector) assert.ok(oldTree, 'could not query selector: ' + selector) const newTree = _router(state.location.pathname, state) @@ -81,7 +82,6 @@ function choo (opts) { // (obj, obj, obj, str, fn) -> null function render (data, state, prev, name, createSend) { if (opts.onState) opts.onState(data, state, prev, name, createSend) - if (state === prev) return const newTree = _router(state.location.pathname, state, prev) _rootNode = yo.update(_rootNode, newTree) diff --git a/package.json b/package.json index d91c355..453e9d5 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "main": "index.js", "scripts": { "deps": "dependency-check . && dependency-check . --extra --no-dev -i xhr", + "test:electron": "browserify tests/**/*.js -t es2020 -p proxyquire-universal | tape-run", + "test:cov": "browserify tests/**/*.js -t es2020 -p proxyquire-universal -p tape-istanbul/plugin | tape-run | tape-istanbul && istanbul report", "test:server": "standard && npm run deps && NODE_ENV=test node tests/server/*", - "test:server:cov": "standard && npm run deps && NODE_ENV=test istanbul cover tests/server/*", "test:browser": "standard && npm run deps && NODE_ENV=test zuul tests/browser/*", "test:browser:local": "standard && npm run deps && NODE_ENV=test zuul --local 8080 -- tests/browser/*", - "test:cov": "npm run test:server:cov", "preversion": "if [ ! -z $SKIP_TEST ]; then npm run test:browser; fi", - "test": "npm run test:server" + "test": "npm run test:electron" }, "repository": "yoshuawuyts/choo", "keywords": [ @@ -25,6 +25,7 @@ "license": "MIT", "dependencies": { "barracks": "^7.0.3", + "document-ready": "~1.0.1", "global": "^4.3.0", "hash-match": "^1.0.2", "sheet-router": "^3.1.0", @@ -33,20 +34,28 @@ "yo-yo": "^1.2.2" }, "devDependencies": { + "append-child": "~1.0.0", "bankai": "^2.0.2", "browserify": "^13.0.1", "browserify-istanbul": "^2.0.0", "bundle-collapser": "^1.2.1", "dependency-check": "^2.5.1", "es2020": "^1.0.1", + "geval": "~2.1.1", "insert-css": "^0.2.0", "istanbul": "^0.4.4", "karma-sauce-launcher": "^1.0.0", + "min-document": "~2.18.0", + "proxyquire": "~1.7.10", + "proxyquire-universal": "~1.0.8", + "proxyquireify": "~3.2.0", "server-router": "^2.1.0", "sheetify": "^5.0.0", "standard": "^7.1.0", "tachyons": "^4.0.0-beta.19", "tape": "^4.5.1", + "tape-istanbul": "~1.0.2", + "tape-run": "~2.1.4", "yo-yoify": "^3.1.0", "zuul": "toddself/zuul" } diff --git a/tests/browser/basic.js b/tests/browser/basic.js index f5397fb..368ca46 100644 --- a/tests/browser/basic.js +++ b/tests/browser/basic.js @@ -1,61 +1,60 @@ -const tape = require('tape') +const test = require('tape') +const append = require('append-child') const choo = require('../../') const view = require('../../html') -tape('should render on the client', function (t) { - t.test('state should not be mutable', function (t) { - t.plan(4) +test('state is immutable', function (t) { + t.plan(4) - const app = choo() - const state = { - foo: 'baz', - beep: 'boop' - } + const app = choo() + const state = { + foo: 'baz', + beep: 'boop' + } - app.model({ - state: state, - namespace: 'test', - reducers: { - 'no-reducer-mutate': (action, state) => { - return {} - }, - 'mutate-on-return': (action, state) => { - delete action.type - return action - } + app.model({ + state: state, + namespace: 'test', + reducers: { + 'no-reducer-mutate': (action, state) => { + return {} }, - effects: { - 'triggers-reducers': (action, state, send) => { - send('test:mutate-on-return', {beep: 'barp'}) - } + 'mutate-on-return': (action, state) => { + delete action.type + return action } - }) - - let loop = -1 - - const asserts = [ - (state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'intial state'), - (state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'no change in state'), - (state) => t.deepEqual(state, {foo: 'oof', beep: 'boop'}, 'change in state from reducer'), - (state) => t.deepEqual(state, {foo: 'oof', beep: 'barp'}, 'change in state from effect') - ] - - const triggers = [ - (send) => send('test:no-reducer-mutate'), - (send) => send('test:mutate-on-return', {foo: 'oof'}), - (send) => send('test:triggers-reducers') - ] - - app.router((route) => [ - route('/', function (params, state, send) { - ++loop - asserts[loop] && asserts[loop](state.test) - setTimeout(() => triggers[loop] && triggers[loop](send), 5) - return view`
${state.foo}:${state.beep}
` - }) - ]) - - const tree = app.start() - document.body.appendChild(tree) + }, + effects: { + 'triggers-reducers': (action, state, send, done) => { + send('test:mutate-on-return', {beep: 'barp'}, done) + } + } }) + + let loop = -1 + + const asserts = [ + (state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'intial state'), + (state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'no change in state'), + (state) => t.deepEqual(state, {foo: 'oof', beep: 'boop'}, 'change in state from reducer'), + (state) => t.deepEqual(state, {foo: 'oof', beep: 'barp'}, 'change in state from effect') + ] + + const triggers = [ + (send) => send('test:no-reducer-mutate'), + (send) => send('test:mutate-on-return', {foo: 'oof'}), + (send) => send('test:triggers-reducers') + ] + + app.router((route) => [ + route('/', function (state, prev, send) { + ++loop + asserts[loop] && asserts[loop](state.test) + setTimeout(() => triggers[loop] && triggers[loop](send), 5) + return view`
${state.foo}:${state.beep}
` + }) + ]) + + const tree = app.start() + t.on('end', append(tree)) }) diff --git a/tests/browser/freeze.js b/tests/browser/freeze.js new file mode 100644 index 0000000..a073bf8 --- /dev/null +++ b/tests/browser/freeze.js @@ -0,0 +1,46 @@ +const test = require('tape') +const choo = require('../../') + +test('freeze (default)', function (t) { + t.plan(2) + const app = choo() + + app.model({ + state: { + foo: 'bar' + } + }) + + app.router((route) => [ + route('/', function (state, prev, send) { + state.foo = '' + t.equal(state.foo, 'bar', 'cannot modify property') + state.bar = 'baz' + t.equal(state.bar, undefined, 'cannot add property') + }) + ]) + + app.start() +}) + +test('noFreeze', function (t) { + t.plan(2) + const app = choo({noFreeze: true}) + + app.model({ + state: { + foo: 'bar' + } + }) + + app.router((route) => [ + route('/', function (state, prev, send) { + state.foo = '' + t.equal(state.foo, '', 'can modify property') + state.bar = 'baz' + t.equal(state.bar, 'baz', 'can add property') + }) + ]) + + app.start() +}) diff --git a/tests/browser/hooks.js b/tests/browser/hooks.js new file mode 100644 index 0000000..278ce01 --- /dev/null +++ b/tests/browser/hooks.js @@ -0,0 +1,56 @@ +const test = require('tape') +const append = require('append-child') +const choo = require('../../') +const view = require('../../html') + +test('hooks', function (t) { + t.plan(9) + + const app = choo({ + onError: function (err) { + t.equal(err.message, 'effect error', 'onError: receives err') + }, + onAction: function (action, state, name, caller, createSend) { + if (name === 'explodes') return + t.deepEqual(action, {foo: 'bar'}, 'onAction: action data') + t.equal(state.clicks, 0, 'onAction: current state: 0 clicks') + t.equal(name, 'click', 'onAction: action name') + t.equal(caller, '/', 'onAction: caller name') + t.equal(typeof createSend, 'function', 'onAction: createSend fn') + }, + onState: function (action, state, prev, createSend) { + t.deepEqual(action, {foo: 'bar'}, 'onState: action data') + t.deepEqual(state.clicks, 1, 'onState: new state: 1 clicks') + t.deepEqual(prev.clicks, 0, 'onState: prev state: 0 clicks') + } + }) + + app.model({ + state: { + clicks: 0 + }, + reducers: { + click: (action, state) => ({clicks: state.clicks + 1}) + }, + effects: { + explodes: (action, state, send, done) => { + setTimeout(() => done(new Error('effect error')), 5) + } + } + }) + + var sent = false + app.router((route) => [ + route('/', function (state, prev, send) { + if (!sent) { + send('click', {foo: 'bar'}) + send('explodes') + } + sent = true + return view`` + }) + ]) + + const tree = app.start() + t.on('end', append(tree)) +}) diff --git a/tests/browser/rehydration.js b/tests/browser/rehydration.js new file mode 100644 index 0000000..1b91995 --- /dev/null +++ b/tests/browser/rehydration.js @@ -0,0 +1,29 @@ +const test = require('tape') +const onReady = require('document-ready') +const append = require('append-child') +const choo = require('../../') +const view = require('../../html') + +test('rehydration', function (t) { + t.plan(2) + + const app = choo() + + app.router((route) => [ + route('/', function (state, prev, send) { + return view`
send('test')}>Hello world!` + }) + ]) + + var node = document.createElement('div') + node.innerHTML = app.toString('/') + node = node.childNodes[0] + t.on('end', append(node)) + + app.start('#app-root') + + onReady(function () { + t.equal(node.innerHTML, 'Hello world!', 'same content') + t.equal(typeof node.onclick, 'function', 'attaches dom listeners') + }) +}) diff --git a/tests/browser/routing.js b/tests/browser/routing.js new file mode 100644 index 0000000..6bf0f36 --- /dev/null +++ b/tests/browser/routing.js @@ -0,0 +1,166 @@ +const test = require('tape') +const Event = require('geval/event') +const proxyquire = require('proxyquire') +const append = require('append-child') +const view = require('../../html') + +test('routing', function (t) { + t.test('history', function (t) { + t.plan(3) + + const history = Event() + const choo = proxyquire('../..', { + 'sheet-router/history': history.listen + }) + + const app = choo() + + app.model({ + state: { + user: null + }, + reducers: { + set: (action, state) => ({user: action.id}) + }, + effects: { + open: function (action, state, send, done) { + t.deepEqual(action, {id: 1}) + send('set', {id: 1}, function (err) { + if (err) return done(err) + history.broadcast('https://foo.com/users/1') + }) + } + } + }) + + app.router('/users', (route) => [ + route('/users', parentView, [ + route('/:user', childView) + ]) + ]) + + const tree = app.start() + t.on('end', append(tree)) + + t.equal(tree.innerHTML.trim(), 'Open') + tree.onclick() + + function parentView (state, prev, send) { + return view` + + ` + } + + function childView (state, prev, send) { + t.equal(state.user, 1) + return view`` + } + }) + + t.test('hash', function (t) { + t.plan(1) + + const hash = Event() + const choo = proxyquire('../..', { + 'sheet-router/hash': hash.listen + }) + + const app = choo() + + app.model({ + state: { + user: null + }, + reducers: { + set: (action, state) => ({user: action.id}) + }, + effects: { + open: function (action, state, send, done) { + send('set', {id: 1}, function (err) { + if (err) return done(err) + hash.broadcast('#users/1') + }) + } + } + }) + + app.router('/users', (route) => [ + route('/users', parentView, [ + route('/:user', childView) + ]) + ]) + + const tree = app.start({hash: true}) + t.on('end', append(tree)) + + tree.onclick() + + function parentView (state, prev, send) { + return view` + + ` + } + + function childView (state, prev, send) { + t.equal(state.user, 1) + return view`` + } + }) + + t.test('disabling history', function (t) { + t.plan(1) + + const choo = proxyquire('../..', { + 'sheet-router/history': () => t.fail('history listener attached') + }) + + const app = choo() + + app.router('/', (route) => [ + route('/', function () { + t.pass('rendered') + }) + ]) + + app.start({history: false}) + }) + + t.test('disabling href', function (t) { + t.plan(1) + + const choo = proxyquire('../..', { + 'sheet-router/href': () => t.fail('href listener attached') + }) + + const app = choo() + + app.router('/', (route) => [ + route('/', function () { + t.pass('rendered') + }) + ]) + + app.start({href: false}) + }) + + t.test('viewless nesting', function (t) { + t.plan(1) + + const choo = require('../..') + const app = choo() + + app.router('/users/123', (route) => [ + route('/users', [ + route('/:user', function (state) { + t.deepEqual(state.params, {user: '123'}) + }) + ]) + ]) + + app.start() + }) +}) diff --git a/tests/server/index.js b/tests/server/index.js index c4e4a67..871b035 100644 --- a/tests/server/index.js +++ b/tests/server/index.js @@ -1,9 +1,10 @@ -const tape = require('tape') +const test = require('tape') +const minDocument = require('min-document') const choo = require('../../') const view = require('../../html') -tape('should render on the server', function (t) { - t.test('should render a static response', function (t) { +test('server', function (t) { + t.test('renders a static html response', function (t) { t.plan(1) const app = choo() @@ -16,7 +17,20 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) - t.test('should accept a state object', function (t) { + t.test('renders without a real DOM', function (t) { + t.plan(1) + + const app = choo() + app.router((route) => [ + route('/', () => minDocument.createElement('div')) + ]) + + const html = app.toString('/') + const expected = '
' + t.equal(html, expected, 'strings are equal') + }) + + t.test('receives a state object', function (t) { t.plan(1) const app = choo() @@ -31,7 +45,7 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) - t.test('should extend flat existing models', function (t) { + t.test('extends flat existing models', function (t) { t.plan(1) const app = choo() @@ -48,7 +62,7 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) - t.test('should extend namespaced existing models', function (t) { + t.test('extends namespaced existing models', function (t) { t.plan(1) const app = choo() @@ -75,7 +89,7 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) - t.test('should throw if called without route', function (t) { + t.test('throws if called without route', function (t) { t.plan(1) const app = choo() @@ -88,7 +102,7 @@ tape('should render on the server', function (t) { t.throws(app.toString.bind(null), /route must be a string/) }) - t.test('should throw if calling send()', function (t) { + t.test('throws when calling send()', function (t) { t.plan(1) const app = choo()