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()