├── .gitignore
├── .npmrc
├── .travis.yml
├── .vscode
└── settings.json
├── README.md
├── actions
└── index.js
├── apps
├── todo-app.js
└── todo.html
├── components
├── hello-world.js
├── pico-world.js
├── server-todos.js
├── todo-item.js
└── todo-list.js
├── cypress.json
├── cypress
├── fixtures
│ └── example.json
├── integration
│ ├── edge-spec.js
│ ├── hello-world-component-spec.js
│ ├── hello-world-spec.js
│ ├── pico-world-spec.js
│ ├── server-todos-spec.js
│ ├── todo-app-e2e.js
│ ├── todo-item-spec.js
│ └── todo-list-spec.js
├── plugins
│ └── index.js
└── support
│ ├── commands.js
│ └── index.js
├── images
└── hello-world.png
├── issue_template.md
├── package.json
├── renovate.json
├── src
└── index.js
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .DS_Store
3 | npm-debug.log
4 | apps/todo-app-bundle.umd.js
5 | apps/todo-app-bundle.umd.js.map
6 | dist
7 | cypress/screenshots/
8 | bundles
9 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=http://registry.npmjs.org/
2 | save-exact=true
3 | progress=false
4 | package-lock=false
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | cache:
3 | directories:
4 | - ~/.npm
5 | - node_modules
6 | notifications:
7 | email: true
8 | node_js:
9 | - '8'
10 | # install peer dependencies we need before installing
11 | # main dependencies so all get cached
12 | before_install:
13 | - npm install cypress hyperapp
14 | script:
15 | # builds and tests everything
16 | - npm run build
17 | - npm run test:ci:record
18 | after_success:
19 | - npm run semantic-release || true
20 | branches:
21 | except:
22 | - /^v\d+\.\d+\.\d+$/
23 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.enable": false,
3 | "git.ignoreLimitWarning": true,
4 | "prettier.semi": false,
5 | "prettier.singleQuote": true,
6 | "prettier.trailingComma": "none",
7 | "editor.formatOnSave": true
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cypress-hyperapp-unit-test
2 |
3 | > Unit test [Hyperapp](https://hyperapp.js.org/) components using [Cypress](https://www.cypress.io/)
4 |
5 | [![NPM][npm-icon] ][npm-url]
6 |
7 | [![Build status][ci-image] ][ci-url]
8 | [![semantic-release][semantic-image] ][semantic-url]
9 | [![js-standard-style][standard-image]][standard-url]
10 | [][cypress dashboard url]
11 | [![renovate-app badge][renovate-badge]][renovate-app]
12 |
13 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg
14 | [renovate-app]: https://renovateapp.com/
15 |
16 | ## TLDR
17 |
18 | * What is this? This package allows you to use [Cypress](https://www.cypress.io/) test runner to unit test your Hyperapp components with zero effort. The component runs in the real browser with full power of Cypress E2E test runner: [live GUI, powerful API, screen recording, historical DOM snapshots, CI support, cross-platform](https://www.cypress.io/features/).
19 |
20 | * The line between unit testing a component that renders into a DOM, makes HTTP requests, uses browser API and an end-to-end test for a complete web application is becoming very blurry in my opinion. Hope this little bridge between Hyperapp and Cypress test runner proves it. See examples below - some of them are testing individual components, some full apps. But the unit and end-to-end tests look and run _very much alike_.
21 |
22 | ## Install
23 |
24 | Requires [Node](https://nodejs.org/en/) version 6 or above.
25 |
26 | ```sh
27 | npm install --save-dev cypress-hyperapp-unit-test
28 | ```
29 |
30 | also requires peer dependencies in your project
31 |
32 | ```sh
33 | npm install cypress hyperapp
34 | ```
35 |
36 | ## API
37 |
38 | You can import this module from your own tests
39 |
40 | ```js
41 | import { mount } from 'cypress-hyperapp-unit-test'
42 | // import or code state, action and view
43 | beforeEach(() => {
44 | mount(state, actions, view)
45 | })
46 | // you get fresh mini-app running in each test
47 | ```
48 |
49 | ## Use
50 |
51 | In your Cypress spec files (the example below is from file [cypress/integration/hello-world-spec.js](cypress/integration/hello-world-spec.js)) mount the application, just like you would "normally".
52 |
53 | ```js
54 | import { mount } from 'cypress-hyperapp-unit-test'
55 | import { h } from 'hyperapp'
56 | // view function we are testing
57 | const view = (state, actions) => h('div', { class: 'greeting' }, 'Hello, World')
58 | describe('Hello World', () => {
59 | beforeEach(() => {
60 | const state = {}
61 | const actions = {}
62 | // no state or actions for this simple example
63 | mount(state, actions, view)
64 | })
65 | it('shows greeting', () => {
66 | // use any Cypress command - we have
67 | // real Hyperapp application for testing
68 | cy.contains('.greeting', 'Hello, World')
69 | })
70 | })
71 | ```
72 |
73 | Start Cypress using `$(npm bin)/cypress open` and execute the spec. You have full end-to-end test run but with your component! Why waste time on unit testing inside synthetic DOM's blackbox if you could _see_ the result, _inspect_ the DOM, _investigate_ how it works using time-travelling debugger?
74 |
75 | 
76 |
77 | ## Examples
78 |
79 | * [simple view function without any actions](cypress/integration/hello-world-spec.js)
80 | * [components without and with actions](cypress/integration/hello-world-component-spec.js)
81 | * [single TodoItem component](cypress/integration/todo-item-spec.js)
82 | * [entire TodoList component](cypress/integration/todo-list-spec.js)
83 | * [server XHR stubbing](cypress/integration/server-todos-spec.js)
84 | * [TodoMVC application E2E test](cypress/integration/todo-app-e2e.js) for [apps/todo.html](apps/todo.html)
85 |
86 | Unit tests and E2E tests start looking very much alike. Compare [TodoList unit test](cypress/integration/todo-list-spec.js) and [TodoMVC end-to-end test](cypress/integration/todo-app-e2e.js).
87 |
88 | * Components and tests for Hyperapp using JSX are their own repository [bahmutov/hyperapp-counter-jsx-example](https://github.com/bahmutov/hyperapp-counter-jsx-example) to keep this repo simple.
89 |
90 | ## Repo organization
91 |
92 | * [src/index.js](src/index.js) the main file implementing `mount`
93 | * [components](components) different Hyper components for testing
94 | * [actions](actions) pure actions functions used from components and tests
95 | * [apps](apps) one or more complete bundled applications (build them using `npm run build`)
96 | * [cypress/integration](cypress/integration) example spec files showing various test situations
97 |
98 | See video of tests running on CI on the project's [Cypress Dashboard][cypress dashboard url]
99 |
100 | ## API Extras
101 |
102 | * Mounted component's actions object is attached to the global `Cypress.main` variable. The name `main` was picked because that's what Hyperapp uses in its docs `const main = app(state, ...)`
103 | * The `mount` function adds an action `_getState` to the `actions` object, if there is not one already present. This allows you to get the current state of the component for inspection.
104 |
105 | ```js
106 | Cypress.main.setName('Joe')
107 | Cypress.main
108 | ._getState()
109 | .its('name')
110 | .should('equal', 'Joe')
111 | Cypress.main.setAge(37)
112 | Cypress.main._getState().should('deep.equal', {
113 | name: 'Joe',
114 | age: 37
115 | })
116 | ```
117 |
118 | Note: the `Cypress.main` wraps returned Hyperapp actions with `cy.then` to queue the calls through the Cypress command queue. Thus the above code looks synchronous, but in reality there could be DOM updates, network calls, etc, and it still works.
119 |
120 | ## Package scripts
121 |
122 | * `npm run build` bundles complete applications if you want to run tests against full applications
123 | * `npm run cy:open` starts Cypress GUI, which is great for TDD mode
124 | * `npm run cy:run` runs Cypress headlessly, testing all specs. Same command [runs on CI](.travis.yml) with additional `--record` argument to record the run and send to the [Cypress Dashboard][cypress dashboard url]
125 |
126 | ## Similar adaptors
127 |
128 | * [cypress-vue-unit-test](https://github.com/bahmutov/cypress-vue-unit-test)
129 | * [cypress-react-unit-test](https://github.com/bahmutov/cypress-react-unit-test)
130 | * [cypress-cycle-unit-test](https://github.com/bahmutov/cypress-cycle-unit-test)
131 | * [cypress-svelte-unit-test](https://github.com/bahmutov/cypress-svelte-unit-test)
132 | * [cypress-angular-unit-test](https://github.com/bahmutov/cypress-angular-unit-test)
133 | * [cypress-hyperapp-unit-test](https://github.com/bahmutov/cypress-hyperapp-unit-test)
134 | * [cypress-angularjs-unit-test](https://github.com/bahmutov/cypress-angularjs-unit-test)
135 |
136 | ### Small print
137 |
138 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2017
139 |
140 | * [@bahmutov](https://twitter.com/bahmutov)
141 | * [glebbahmutov.com](https://glebbahmutov.com)
142 | * [blog](https://glebbahmutov.com/blog)
143 |
144 | License: MIT - do anything with the code, but don't blame me if it does not work.
145 |
146 | Support: if you find any problems with this module, email / tweet /
147 | [open issue](https://github.com/bahmutov/cypress-hyperapp-unit-test/issues) on Github
148 |
149 | ## MIT License
150 |
151 | Copyright (c) 2017 Gleb Bahmutov <gleb.bahmutov@gmail.com>
152 |
153 | Permission is hereby granted, free of charge, to any person
154 | obtaining a copy of this software and associated documentation
155 | files (the "Software"), to deal in the Software without
156 | restriction, including without limitation the rights to use,
157 | copy, modify, merge, publish, distribute, sublicense, and/or sell
158 | copies of the Software, and to permit persons to whom the
159 | Software is furnished to do so, subject to the following
160 | conditions:
161 |
162 | The above copyright notice and this permission notice shall be
163 | included in all copies or substantial portions of the Software.
164 |
165 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
166 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
167 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
168 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
169 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
170 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
171 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
172 | OTHER DEALINGS IN THE SOFTWARE.
173 |
174 | [npm-icon]: https://nodei.co/npm/cypress-hyperapp-unit-test.svg?downloads=true
175 | [npm-url]: https://npmjs.org/package/cypress-hyperapp-unit-test
176 | [ci-image]: https://travis-ci.org/bahmutov/cypress-hyperapp-unit-test.svg?branch=master
177 | [ci-url]: https://travis-ci.org/bahmutov/cypress-hyperapp-unit-test
178 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
179 | [semantic-url]: https://github.com/semantic-release/semantic-release
180 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg
181 | [standard-url]: http://standardjs.com/
182 | [cypress dashboard url]: https://dashboard.cypress.io/#/projects/zsoa27
183 |
--------------------------------------------------------------------------------
/actions/index.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | // common pure actions for different components AND tests
4 |
5 | export const toggle = ({ id, completed }) => state => {
6 | // poor man's find and toggle todo, can be really shortened
7 | // with Ramda or other functional libraries.
8 |
9 | // Luckily such pure functions are
10 | // SUPER SIMPLE to test and refactor
11 | // as much as you want
12 | return {
13 | todos: state.todos.map(
14 | t => (t.id === id ? Object.assign({}, t, { completed }) : t)
15 | )
16 | }
17 | }
18 |
19 | export const getTodos = (n = 5, loaded) => {
20 | const url = `http://jsonplaceholder.typicode.com/todos?_limit=${n}`
21 | return axios.get(url).then(res => res.data).then(list => {
22 | console.log('got %d todos', list.length)
23 | if (loaded) {
24 | loaded(list)
25 | }
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/apps/todo-app.js:
--------------------------------------------------------------------------------
1 | import { h, app } from 'hyperapp'
2 | import { TodoList } from '../components/todo-list'
3 | import { toggle } from '../actions'
4 |
5 | // const { h, app } = window.hyperapp
6 |
7 | // notice how much this application looks like TodoList "unit" test
8 | // in cypress/integration/todo-list-spec.js
9 |
10 | console.log('starting todo app')
11 | const state = {
12 | todos: [
13 | {
14 | id: 1,
15 | title: 'Write HyperApp',
16 | completed: false
17 | },
18 | {
19 | id: 2,
20 | title: 'Test it using Cypress',
21 | completed: false
22 | }
23 | ]
24 | }
25 | const actions = { toggle }
26 | const view = (state, actions) =>
27 | // renders TodoList component, passing
28 | // current state and actions
29 | h(TodoList, {
30 | todos: state.todos,
31 | toggle: actions.toggle
32 | })
33 |
34 | app(state, actions, view, document.getElementById('app'))
35 |
--------------------------------------------------------------------------------
/apps/todo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/components/hello-world.js:
--------------------------------------------------------------------------------
1 | import { h } from 'hyperapp'
2 |
3 | // hard-coded greeting message
4 | export const HelloWorld = (state, actions) =>
5 | h('div', { class: 'greeting' }, 'Hello, World')
6 |
7 | // dynamic greeting message
8 | export const HelloYou = (state, actions) =>
9 | h('div', { class: 'greeting' }, `Hello, ${state.name}`)
10 |
--------------------------------------------------------------------------------
/components/pico-world.js:
--------------------------------------------------------------------------------
1 | import { h } from 'hyperapp'
2 | import picostyle from 'picostyle'
3 |
4 | const style = picostyle(h)
5 | const theme = 'hotpink' // Try change the theme to white
6 |
7 | const keyColor = '#f07'
8 |
9 | export const Wrapper = style('div')({
10 | display: 'flex',
11 | justifyContent: 'center',
12 | alignItems: 'center',
13 | width: '100vw',
14 | height: '100vh',
15 | backgroundColor: keyColor
16 | })
17 |
18 | export const Text = style('h1')({
19 | fontSize: 'calc(10px + 5vmin)',
20 | color: theme === 'white' ? 'black' : 'white',
21 | margin: 'auto',
22 | transition: 'transform .2s ease-out',
23 | ':hover': {
24 | transform: 'scale(1.2)'
25 | },
26 | '@media (orientation: landscape)': {
27 | fontWeight: 'bold'
28 | }
29 | })
30 |
--------------------------------------------------------------------------------
/components/server-todos.js:
--------------------------------------------------------------------------------
1 | import { h } from 'hyperapp'
2 | import { TodoList } from './todo-list'
3 | import { getTodos } from '../actions'
4 |
5 | // Todo list that fetches itself from the remote server
6 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/lifecycle-events.md
7 | // http://jsonplaceholder.typicode.com/
8 |
9 | // n - how many todos to fetch from the server
10 | export const ServerTodos = ({ n, todosLoaded, todos, toggle }) => {
11 | const loadTodos = () => getTodos(n, todosLoaded)
12 |
13 | return h(
14 | 'div',
15 | {
16 | oncreate: loadTodos
17 | },
18 | [
19 | h(TodoList, {
20 | todos,
21 | toggle
22 | })
23 | ]
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/components/todo-item.js:
--------------------------------------------------------------------------------
1 | import { h } from 'hyperapp'
2 |
3 | // TodoItem component from
4 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/components.md
5 | export const TodoItem = ({ id, title, completed, toggle }) => {
6 | completed = Boolean(completed)
7 |
8 | const onclick = e =>
9 | toggle({
10 | completed: !completed,
11 | id
12 | })
13 |
14 | const className = `todo ${completed ? 'done' : ''}`
15 |
16 | return h(
17 | 'li',
18 | { class: className, onclick },
19 | h('div', { class: 'view' }, [
20 | h('input', {
21 | class: 'toggle',
22 | type: 'checkbox',
23 | checked: completed
24 | }),
25 | h('label', null, title)
26 | ])
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/components/todo-list.js:
--------------------------------------------------------------------------------
1 | import { h } from 'hyperapp'
2 | import { TodoItem } from './todo-item'
3 |
4 | // TodoList component from
5 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/components.md
6 | export const TodoList = ({ todos, toggle }) =>
7 | h('div', {}, [
8 | h('h1', {}, 'Todo'),
9 | h(
10 | 'ul',
11 | { class: 'todo-list' },
12 | // for each todo object, call TodoItem view function,
13 | // combining todo with toggle action and passing as properties object
14 | todos.map(todo => h(TodoItem, Object.assign({}, todo, { toggle })))
15 | )
16 | ])
17 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "viewportHeight": 200,
3 | "viewportWidth": 300,
4 | "video": false,
5 | "projectId": "zsoa27",
6 | "defaultCommandTimeout": 4000
7 | }
8 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
--------------------------------------------------------------------------------
/cypress/integration/edge-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { h } from 'hyperapp'
4 | import { mount } from '../../src'
5 |
6 | const view = (/* state, actions */) =>
7 | h('div', { class: 'greeting' }, 'edge cases')
8 |
9 | /* eslint-env mocha */
10 | describe('Edge cases', () => {
11 | it('works without state', () => {
12 | mount(null, {}, view)
13 | cy.contains('.greeting', 'edge cases').then(() => {
14 | Cypress.main._getState().should('be', null)
15 | })
16 | })
17 |
18 | it('works without actions', () => {
19 | mount(null, null, view)
20 | cy.contains('.greeting', 'edge cases').then(() => {
21 | Cypress.main._getState().should('be', null)
22 | })
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/cypress/integration/hello-world-component-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { HelloWorld, HelloYou } from '../../components/hello-world'
4 | import { mount } from '../../src'
5 |
6 | /* eslint-env mocha */
7 | describe('HelloWorld', () => {
8 | // component without any actions or internal state
9 | beforeEach(() => {
10 | const state = {}
11 | const actions = {}
12 | mount(state, actions, HelloWorld)
13 | })
14 |
15 | it('shows greeting', () => {
16 | cy.contains('.greeting', 'Hello, World')
17 | })
18 | })
19 |
20 | describe('HelloYou', () => {
21 | // component with state and an action
22 | const state = {
23 | name: 'person'
24 | }
25 |
26 | beforeEach(() => {
27 | const actions = {
28 | setName: name => state => ({ name })
29 | }
30 | mount(state, actions, HelloYou)
31 | })
32 |
33 | it('shows greeting', () => {
34 | cy.contains('.greeting', 'Hello, person')
35 | })
36 |
37 | it('changes greeting using action', () => {
38 | // Cypress.main is the mounted value which is
39 | // the app's actions object
40 | cy.log('changing name')
41 | Cypress.main.setName('Great Person!')
42 | cy.contains('.greeting', 'Hello, Great Person!')
43 | })
44 |
45 | it('mutates name in the state', () => {
46 | Cypress.main.setName('Great Person!')
47 | // mount function adds utility method to get the
48 | // current state object
49 | Cypress.main._getState().its('name').should('equal', 'Great Person!')
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/cypress/integration/hello-world-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { h } from 'hyperapp'
4 | import { mount } from '../../src'
5 |
6 | // view function we are testing - generates "static" content
7 | // so we don't even need state and actions arguments
8 | const view = (/* state, actions */) =>
9 | h('div', { class: 'greeting' }, 'Hello, World')
10 |
11 | /* eslint-env mocha */
12 | describe('Hello World', () => {
13 | // simplest case - no state, no actions
14 | // just a view function
15 | beforeEach(() => {
16 | const state = {}
17 | const actions = {}
18 | mount(state, actions, view)
19 | })
20 |
21 | it('shows greeting', () => {
22 | cy.contains('.greeting', 'Hello, World')
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/cypress/integration/pico-world-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { h } from 'hyperapp'
4 | import { mount } from '../../src'
5 | import { Wrapper, Text } from '../../components/pico-world'
6 |
7 | /* eslint-env mocha */
8 | describe('Picostyle Text', () => {
9 | beforeEach(() => {
10 | cy.window().its('document').then(doc => {
11 | console.log('doc', doc)
12 | })
13 | const view = state => h(Wrapper, {}, [h(Text, {}, [`Hello ${state.text}`])])
14 |
15 | mount(
16 | {
17 | text: 'Picostyle'
18 | },
19 | {},
20 | view
21 | )
22 | })
23 |
24 | it.skip('shows greeting', () => {
25 | cy.contains('Hello Picostyle')
26 | })
27 | })
28 |
--------------------------------------------------------------------------------
/cypress/integration/server-todos-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { h } from 'hyperapp'
4 | import { ServerTodos } from '../../components/server-todos'
5 | import { mount } from '../../src'
6 | import { toggle } from '../../actions'
7 |
8 | /* eslint-env mocha */
9 | describe('Server Todos', () => {
10 | const mockTodos = [
11 | {
12 | id: 1,
13 | title: 'Stub server',
14 | completed: false
15 | },
16 | {
17 | id: 2,
18 | title: 'Test app',
19 | completed: false
20 | },
21 | {
22 | id: 3,
23 | title: 'Profit!',
24 | completed: true
25 | }
26 | ]
27 | beforeEach(() => {
28 | // expect XHR from the component
29 | // and respond with mock list
30 | cy.server()
31 | cy.route('/todos?_limit=3', mockTodos).as('todos')
32 | })
33 |
34 | context('Stubbed server', () => {
35 | beforeEach(() => {
36 | const state = {
37 | todos: []
38 | }
39 | const actions = {
40 | setTodos: todos => state => ({ todos }),
41 | toggle
42 | }
43 | const view = (state, actions) =>
44 | h(ServerTodos, {
45 | n: 3,
46 | todosLoaded: actions.setTodos,
47 | todos: state.todos,
48 | toggle: actions.toggle
49 | })
50 | mount(state, actions, view)
51 | })
52 |
53 | it('shows todos', () => {
54 | cy.contains('Todo')
55 | cy.get('.todo').should('have.length', 3)
56 | cy.get('.todo').first().contains('Stub server')
57 | })
58 |
59 | it('stubs XHR response', () => {
60 | cy.wait('@todos').its('response.body').should('deep.equal', mockTodos)
61 | })
62 |
63 | it('can toggle item', () => {
64 | cy.get('.todo').first().click()
65 | cy.get('.todo').first().find('.toggle').should('be.checked')
66 | })
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/cypress/integration/todo-app-e2e.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | /* eslint-env mocha */
4 | describe('Todo App E2E', () => {
5 | beforeEach(() => {
6 | // optional stubbing of Todos from a server
7 | // can be done like this
8 | // cy.server()
9 | // cy
10 | // .route('/todos?_limit=3', [
11 | // {
12 | // id: 100,
13 | // completed: true,
14 | // title: 'stub server'
15 | // }
16 | // ])
17 | // .as('todos')
18 |
19 | // note this is end-to-end test for entire applicsation
20 | // instead of loading individusl components
21 | // we are just visiting a page - we assume the
22 | // application has been bundled, loaded and initialized
23 | cy.visit('bundles/todo/todo.html')
24 | })
25 |
26 | it('loads Todo app', () => {
27 | cy.contains('h1', 'Todo')
28 | })
29 |
30 | it('shows 2 todos', () => {
31 | cy.get('.todo').should('have.length', 2)
32 | })
33 |
34 | it('toggles second todo several times', () => {
35 | cy
36 | .get('.todo')
37 | .eq(1)
38 | .click()
39 | .click()
40 | .click()
41 | .find('.toggle')
42 | .should('be.checked')
43 | })
44 |
45 | it('completes first todo', () => {
46 | cy.get('.todo').first().click().find('.toggle').should('be.checked')
47 | // second item is still not checked
48 | cy.get('.todo').eq(1).find('.toggle').should('not.be.checked')
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/cypress/integration/todo-item-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { h } from 'hyperapp'
4 | import { TodoItem } from '../../components/todo-item'
5 | import { mount } from '../../src'
6 |
7 | /* eslint-env mocha */
8 | describe('TodoItem', () => {
9 | beforeEach(() => {
10 | const state = {
11 | title: 'Try HyperApp',
12 | completed: false
13 | }
14 | const actions = {
15 | toggle: () => state => ({ completed: !state.completed })
16 | }
17 | const view = (state, actions) =>
18 | h(TodoItem, {
19 | id: 1,
20 | title: state.title,
21 | completed: state.completed,
22 | toggle: actions.toggle
23 | })
24 | mount(state, actions, view)
25 | })
26 |
27 | it('shows todo', () => {
28 | cy.contains('Try HyperApp')
29 | })
30 |
31 | it('is unchecked for unfinished item', () => {
32 | cy.get('.toggle').should('have.length', 1).should('not.be.checked')
33 | })
34 |
35 | it('toggles item on click', () => {
36 | cy.get('.todo').click()
37 | cy.get('.toggle').should('be.checked')
38 | })
39 |
40 | it('changes state on click', () => {
41 | cy.get('.todo').click()
42 | Cypress.main._getState().should('deep.equal', {
43 | completed: true,
44 | title: 'Try HyperApp'
45 | })
46 |
47 | // click again
48 | cy.get('.todo').click()
49 | Cypress.main._getState().should('deep.equal', {
50 | completed: false,
51 | title: 'Try HyperApp'
52 | })
53 | })
54 |
55 | it('changes state by invoking action', () => {
56 | // the component's actions are referenced in Cypress.main
57 | Cypress.main.toggle()
58 | cy.get('.toggle').should('be.checked')
59 | // because actions inside Cypress.main are queued into the
60 | // Cypress command queue first we can just call them
61 | // the toggle below will happen AFTER the toggle check above
62 | // has already passed
63 | Cypress.main.toggle()
64 | cy.get('.toggle').should('not.be.checked')
65 | })
66 | })
67 |
--------------------------------------------------------------------------------
/cypress/integration/todo-list-spec.js:
--------------------------------------------------------------------------------
1 | ///
2 | import { h } from 'hyperapp'
3 | import { TodoList } from '../../components/todo-list'
4 | import { toggle } from '../../actions'
5 | import { mount } from '../../src'
6 |
7 | /* eslint-env mocha */
8 | describe('TodoList', () => {
9 | // let's test TodoList component by giving it simple
10 | // state and a toggle action
11 |
12 | // initial state
13 | const state = {
14 | todos: [
15 | {
16 | id: 1,
17 | title: 'Try HyperApp',
18 | completed: true
19 | },
20 | {
21 | id: 2,
22 | title: 'Test using Cypress',
23 | completed: true
24 | },
25 | {
26 | id: 3,
27 | title: 'Profit!!!',
28 | completed: false
29 | }
30 | ]
31 | }
32 | // reuse toggle action in the test - why not?
33 | const actions = {
34 | toggle
35 | }
36 | const view = (state, actions) =>
37 | // renders TodoList component, passing
38 | // current state and actions
39 | h(TodoList, {
40 | todos: state.todos,
41 | toggle: actions.toggle
42 | })
43 |
44 | beforeEach(() => {
45 | mount(state, actions, view)
46 | })
47 |
48 | it('shows todo', () => {
49 | cy.contains('Todo')
50 | })
51 |
52 | it('has expected number of todos', () => {
53 | cy.get('.todo-list .todo').should('have.length', 3)
54 | })
55 |
56 | it('has 2 completed todos', () => {
57 | cy.get('.todo-list .todo.done').should('have.length', 2)
58 | })
59 |
60 | it('is done with testing', () => {
61 | cy
62 | .contains('.todo-list .todo', 'Test using Cypress')
63 | .find('.toggle')
64 | .should('be.checked')
65 | })
66 |
67 | it('does not have profit yet', () => {
68 | cy
69 | .contains('.todo-list .todo', 'Profit')
70 | .find('.toggle')
71 | .should('not.be.checked')
72 | })
73 |
74 | it('completes todo by clicking', () => {
75 | cy.contains('.todo-list .todo', 'Profit').click()
76 | cy
77 | .contains('.todo-list .todo', 'Profit')
78 | .find('.toggle')
79 | .should('be.checked')
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example plugins/index.js can be used to load plugins
3 | //
4 | // You can change the location of this file or turn off loading
5 | // the plugins file with the 'pluginsFile' configuration option.
6 | //
7 | // You can read more here:
8 | // https://on.cypress.io/plugins-guide
9 | // ***********************************************************
10 |
11 | // This function is called when a project is opened or re-opened (e.g. due to
12 | // the project's config changing)
13 |
14 | module.exports = (on, config) => {
15 | // `on` is used to hook into various events Cypress emits
16 | // `config` is the resolved Cypress config
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This is will overwrite an existing command --
25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
26 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/images/hello-world.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bahmutov/cypress-hyperapp-unit-test/ffe91bb180820ac99fe820f45696c331659faa78/images/hello-world.png
--------------------------------------------------------------------------------
/issue_template.md:
--------------------------------------------------------------------------------
1 | Thank you for taking time to open a new issue. Please answer a few questions to help us fix it faster. You can delete text that is irrelevant to the issue.
2 |
3 | ## Is this a bug report or a feature request?
4 |
5 | If this is a bug report, please provide as much info as possible
6 |
7 | - version
8 | - platform
9 | - expected behavior
10 | - actual behavior
11 |
12 | If this is a new feature request, please describe it below
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cypress-hyperapp-unit-test",
3 | "description": "Unit test Hyperapp components using Cypress",
4 | "version": "0.0.0-development",
5 | "author": "Gleb Bahmutov ",
6 | "bugs": "https://github.com/bahmutov/cypress-hyperapp-unit-test/issues",
7 | "config": {
8 | "pre-git": {
9 | "commit-msg": "simple",
10 | "pre-commit": [
11 | "npm test",
12 | "git add src/*.js",
13 | "npm run ban"
14 | ],
15 | "pre-push": [
16 | "npm run secure",
17 | "npm run license",
18 | "npm run ban -- --all",
19 | "npm run size"
20 | ],
21 | "post-commit": [],
22 | "post-merge": []
23 | }
24 | },
25 | "engines": {
26 | "node": ">=6"
27 | },
28 | "files": [
29 | "src/*.js",
30 | "!src/*-spec.js",
31 | "dist"
32 | ],
33 | "homepage": "https://github.com/bahmutov/cypress-hyperapp-unit-test#readme",
34 | "keywords": [
35 | "cypress",
36 | "cypress-io",
37 | "hyperapp",
38 | "test"
39 | ],
40 | "license": "MIT",
41 | "main": "dist",
42 | "module": "src",
43 | "private": false,
44 | "publishConfig": {
45 | "registry": "http://registry.npmjs.org/"
46 | },
47 | "repository": {
48 | "type": "git",
49 | "url": "https://github.com/bahmutov/cypress-hyperapp-unit-test.git"
50 | },
51 | "scripts": {
52 | "ban": "ban",
53 | "deps": "deps-ok && dependency-check --no-dev .",
54 | "issues": "git-issues",
55 | "license": "license-checker --production --onlyunknown --csv",
56 | "lint": "standard --verbose --fix src/*.js",
57 | "prelint": "npm run pretty",
58 | "pretest": "npm run lint",
59 | "pretty": "prettier-standard 'src/*.js'",
60 | "secure": "nsp check",
61 | "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";",
62 | "test": "cypress run",
63 | "pretest:ci": "npm run build",
64 | "test:ci": "npm run test",
65 | "test:ci:record": "npm run cy:run:record",
66 | "cy:open": "cypress open",
67 | "cy:run": "cypress run",
68 | "cy:run:record": "cypress run --record",
69 | "unit": "mocha src/*-spec.js",
70 | "unused-deps": "dependency-check --unused --no-dev .",
71 | "semantic-release": "semantic-release",
72 | "transpile": "tsc",
73 | "prebuild": "npm run transpile",
74 | "build": "npm run build:todo-app",
75 | "build:todo-app": "parcel build apps/todo.html -d bundles/todo --public-url ./"
76 | },
77 | "release": {
78 | "analyzeCommits": "simple-commit-message"
79 | },
80 | "devDependencies": {
81 | "@types/node": "9.6.61",
82 | "axios": "0.21.1",
83 | "ban-sensitive-files": "1.9.15",
84 | "common-tags": "1.8.0",
85 | "cypress": "3.8.3",
86 | "dependency-check": "3.4.1",
87 | "deps-ok": "1.4.1",
88 | "git-issues": "1.3.1",
89 | "hyperapp": "1.2.10",
90 | "license-checker": "20.2.0",
91 | "mocha": "5.2.0",
92 | "nsp": "3.2.1",
93 | "parcel-bundler": "1.12.4",
94 | "picostyle": "2.2.0",
95 | "pre-git": "3.17.1",
96 | "prettier-standard": "8.0.1",
97 | "simple-commit-message": "4.1.2",
98 | "standard": "12.0.1",
99 | "typescript": "3.9.9",
100 | "semantic-release": "15.14.0"
101 | },
102 | "standard": {
103 | "globals": [
104 | "Cypress",
105 | "cy",
106 | "expect"
107 | ]
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:base"],
3 | "automerge": true,
4 | "major": {
5 | "automerge": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { stripIndent } from 'common-tags'
2 |
3 | // having weak reference to styles prevents garbage collection
4 | // and "losing" styles when the next test starts
5 | const stylesCache = new Map()
6 |
7 | const copyStyles = component => {
8 | // need to find same component when component is recompiled
9 | // by the JSX preprocessor. Thus have to use something else,
10 | // like component name
11 | // const hash = component.type.name
12 | const hash = component
13 |
14 | let styles = document.querySelectorAll('head style')
15 | if (styles.length) {
16 | console.log('injected %d styles', styles.length)
17 | stylesCache.set(hash, styles)
18 | } else {
19 | console.log('No styles injected for this component, checking cache')
20 | if (stylesCache.has(hash)) {
21 | styles = stylesCache.get(hash)
22 | } else {
23 | styles = null
24 | }
25 | }
26 |
27 | if (!styles) {
28 | return
29 | }
30 |
31 | const parentDocument = window.parent.document
32 | const projectName = Cypress.config('projectName')
33 | const appIframeId = `Your App: '${projectName}'`
34 | const appIframe = parentDocument.getElementById(appIframeId)
35 | const head = appIframe.contentDocument.querySelector('head')
36 | styles.forEach(style => {
37 | head.appendChild(style)
38 | })
39 | }
40 |
41 | function setXMLHttpRequest (w) {
42 | // by grabbing the XMLHttpRequest from app's iframe
43 | // and putting it here - in the test iframe
44 | // we suddenly get spying and stubbing 😁
45 | window.XMLHttpRequest = w.XMLHttpRequest
46 | return w
47 | }
48 |
49 | function setAlert (w) {
50 | window.alert = w.alert
51 | return w
52 | }
53 |
54 | export const mount = (state, actions, view) => {
55 | if (!actions) {
56 | // we always want to have an actions object so we
57 | // can attach _getState utility function
58 | actions = {}
59 | }
60 |
61 | // TODO stop hard coding Hyperapp version, grab from the "node_modules"
62 | const html = stripIndent`
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | `
71 | const document = cy.state('document')
72 | document.write(html)
73 | document.close()
74 |
75 | // add a utility action to get the current state
76 | if (!actions._getState) {
77 | const _getState = () => state => state
78 | actions = Object.assign({}, actions, { _getState })
79 | }
80 |
81 | // force waiting for window.hyperapp to exist before anything else happens
82 | cy
83 | .window({ log: false })
84 | .its('hyperapp')
85 | .should('be.an', 'object')
86 |
87 | // now can attach our handlers
88 | cy
89 | .window({ log: false })
90 | .then(setXMLHttpRequest)
91 | .then(setAlert)
92 | .its('hyperapp.app')
93 | .then(app => {
94 | const el = document.getElementById('app')
95 | const main = app(state, actions, view, el)
96 |
97 | // wrap every hyper action with `cy.then` to
98 | // make sure it goes through the Cypress command queue
99 | // allows things like the example below to just work
100 | // Cypress.main.setText('foo')
101 | // cy.contains('foo')
102 | // Cypress.main.setText('bar')
103 | // cy.contains('bar')
104 | Cypress.main = {}
105 | Object.keys(main).forEach(name => {
106 | const action = main[name]
107 | Cypress.main[name] = function queueAction () {
108 | // should we log arguments?
109 | cy.log(`action: ${name}`)
110 | return cy.then(() => action.apply(null, arguments))
111 | }
112 | })
113 |
114 | return Cypress.main
115 | })
116 |
117 | cy.get('#app', { log: false }).should('be.visible')
118 |
119 | // picostyle dom is more complex
120 | // picostyle inserts a style into the document's head
121 | // but then it actually inserts new CSS rules when the view instantiates
122 | // so we need to _observe_ test document style and on added
123 | // CSS rules copy them
124 | copyStyles(view)
125 | }
126 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
6 | // "lib": [], /* Specify library files to be included in the compilation: */
7 | "allowJs": true, /* Allow javascript files to be compiled. */
8 | // "checkJs": true, /* Report errors in .js files. */
9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
11 | // "sourceMap": true, /* Generates corresponding '.map' file. */
12 | // "outFile": "./", /* Concatenate and emit output to single file. */
13 | "outDir": "dist", /* Redirect output structure to the directory. */
14 | // "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
15 | // "removeComments": true, /* Do not emit comments to output. */
16 | // "noEmit": true, /* Do not emit outputs. */
17 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
18 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
19 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
20 |
21 | /* Strict Type-Checking Options */
22 | "strict": false /* Enable all strict type-checking options. */
23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
24 | // "strictNullChecks": true, /* Enable strict null checks. */
25 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
26 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
27 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
28 |
29 | /* Additional Checks */
30 | // "noUnusedLocals": true, /* Report errors on unused locals. */
31 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
32 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
33 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
34 |
35 | /* Module Resolution Options */
36 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
37 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
38 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
39 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
40 | // "typeRoots": [], /* List of folders to include type definitions from. */
41 | // "types": [], /* Type declaration files to be included in compilation. */
42 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
43 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
44 |
45 | /* Source Map Options */
46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
50 |
51 | /* Experimental Options */
52 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
53 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
54 | },
55 | "include": [
56 | "src/index.js"
57 | ]
58 | }
59 |
--------------------------------------------------------------------------------