Trying ArrowJS

Trying the tiny framework Arrow.js with Vite and Cypress.

Recently, I played with ArrowJS - a tiny reactive web framework that uses JavaScript for everything. Here is a typical "counter" application code.

1
2
3
4
5
6
7
8
9
10
11
import { reactive, html } from '@arrow-js/core'

const data = reactive({
clicks: 0
});

html`
<button @click="${() => data.clicks++}">
Fired ${() => data.clicks} arrows
</button>
`

How would you run this example in your project? Let's find out.

Zero build tools

First, let's try the above example without any build tools, just using a modern browser. In an empty folder, I will create index.html and will import the ArrowJS from CDN:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<body>
<div id="app"></div>
<script type="module">
import { reactive, html } from 'https://cdn.skypack.dev/@arrow-js/core'

const data = reactive({
clicks: 0,
})

const template = html`
<button @click="${() => data.clicks++}">
Fired ${() => data.clicks} arrows
</button>
`

const appElement = document.getElementById('app')
template(appElement)
</script>
</body>

Wow, I can open this local index.html in my browser and it works.

ArrowJS counter works from a local index HTML file

Beautiful. You can find this code in the branch zero of the repo bahmutov/arrowjs-test-examples.

Build using Vite

What if we want to split the source code? Trying to move some of the source code does not work. For example, moving the "counter" into its own module and importing from the index.html breaks when using the local file in the browser.

src/counter.js
1
2
3
4
5
6
7
8
9
10
11
import { reactive, html } from 'https://cdn.skypack.dev/@arrow-js/core'

const data = reactive({
clicks: 0,
})

export const counter = html`
<button @click="${() => data.clicks++}">
Fired ${() => data.clicks} arrows
</button>
`
index.html
1
2
3
4
5
6
7
8
<body>
<div id="app"></div>
<script type="module">
import { counter } from './src/counter.js'
const appElement = document.getElementById('app')
counter(appElement)
</script>
</body>

Cannot import other local modules from index.html file

Ok, we probably do need to bundle things to run them locally. Let's use Vite, and while we are at it, let's use a local ArrowJS NPM module.

1
2
3
4
$ npm init --yes
$ npm i -D @arrow-js/core vite
+ @arrow-js/[email protected]
+ @[email protected]

In my package.json I use dev script to run vite

package.json
1
2
3
4
5
{
"scripts": {
"dev": "vite"
}
}

Let's start the development server with npm start

1
2
3
4
5
6
7
$ npm run dev

VITE v4.1.1 ready in 172 ms

➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose
➜ press h to show help

The application works again

Vite works with ArrowJS application

The best thing - the page reloads almost instantly if we change any of the code. For example, let's change the initial data object in the src/counter.js

Changing the source reloads the page almost instantly

You can find this code in the branch vite of the repo bahmutov/arrowjs-test-examples.

End-to-end test

Now let's test the counter using Cypress end-to-end tests. We need to install Cypress and scaffold the config file and the first spec. We can use @bahmutov/cly

1
2
3
4
$ npm i -D cypress
+ [email protected]

$ npx @bahmutov/cly init -b

Let's put settings for our E2E tests into cypress.config.js file

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { defineConfig } = require('cypress')

module.exports = defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
viewportHeight: 200,
viewportWidth: 200,
baseUrl: 'http://127.0.0.1:5173/',
setupNodeEvents(on, config) {
// implement node event listeners here
// and load any plugins that require the Node environment
},
},
})

Our test should check if clicking increments the count

cypress/e2e/click.cy.js
1
2
3
4
5
6
7
8
/// <reference types="cypress" />

it('increments the count', () => {
cy.visit('/')
cy.contains('button', 'Fired 0 arrows').click()
cy.contains('button', 'Fired 1 arrows').click()
cy.contains('button', 'Fired 2 arrows')
})

In the first terminal I will run Vite dev server. In the second terminal, I open Cypress

1
2
3
4
5
# first terminal
$ npm run dev

# second terminal
$ npx cypress open

The counter page end-to-end test

You can find this code in the branch e2e of the repo bahmutov/arrowjs-test-examples.

Component test

Do we need to run Vite dev server? Do we need to load the full application page just to confirm the counter works? We can work and test individual ArrowJS "components" (which are really just the exported HTML template functions). Stop Vite dev server and open Cypress again. Pick "Component Testing" and pick the "React.js" front-end framework and "Vite" bundler

Setting up component testing

Cypress will ask you to install React NPM dependencies, but you skip this step.

Skip installing React dependencies, since we are using ArrowJS

Then Cypress will freak out about missing Vite config.

Cypress cannot find Vite config file

Create an empty file vite.config.js and add the following code:

vite.config.js
1
2
3
import { defineConfig } from 'vite'

export default defineConfig({})

Super. Now open in the code editor cypress/support/component.js. Since we picked React, this file tries to import it. Remove everything from that file and replace the code with our own cy.mount custom command. This command will take the HTML template function produced by ArrowJS an will apply it to an element. The element comes from cypress/support/component-index.html

cypress/support/component-index.html
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
cypress/support/component.js
1
2
3
4
5
6
function mount(template) {
const element = document.querySelector('[data-cy-root]')
template(element)
}

Cypress.Commands.add('mount', mount)

Let's write a component test. I like placing the component tests right next to the components in the src folder. Let's tell Cypress about component spec files and where to find them:

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { defineConfig } = require('cypress')

module.exports = defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
viewportHeight: 200,
viewportWidth: 200,
baseUrl: 'http://127.0.0.1:5173/',
},

component: {
specPattern: 'src/**/*.cy.js',
viewportHeight: 200,
viewportWidth: 200,
devServer: {
framework: 'react',
bundler: 'vite',
},
},
}

Cypress Component Testing shows a missing React dependency warning, but ignore it.

Cypress warns about missing unnecessary React dependencies

Let's write component spec

src/counter.cy.js
1
2
3
4
5
6
7
8
import { counter } from './counter'

it('increments the count', () => {
cy.mount(counter)
cy.contains('button', 'Fired 0 arrows').click()
cy.contains('button', 'Fired 1 arrows').click()
cy.contains('button', 'Fired 2 arrows')
})

The test looks so much like the end-to-end spec cypress/e2e/click.cy.js, only instead of cy.visit('/') command, we are importing the component and mounting it ourselves using the cy.mount(...) command. The component runs like a mini web application, just like the end-to-end page.

ArrowJS Cypress component test

The E2E test takes longer - because it waits for the cy.visit to finish. In this example, both tests are fast, but in a realistic application, loading and testing each component can bring significant speed savings.

You can find this code in the branch component of the repo bahmutov/arrowjs-test-examples.

Continuous integration

Let's run our tests on each commit. We want to run both end-to-end and component tests. The simplest way is to use the GitHub Actions via my reusable Cypress workflows. Here is the workflow file

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: ci
on: [push]
jobs:
e2e:
# use the reusable workflow to check out the code, install dependencies
# and run the Cypress tests
# https://github.com/bahmutov/cypress-workflows
uses: bahmutov/cypress-workflows/.github/workflows/standard.yml@v1
with:
start: npm run dev

component:
uses: bahmutov/cypress-workflows/.github/workflows/standard.yml@v1
with:
component: true

Push the code and observe the results.

GitHub Actions workflow in progress

Once the test jobs finish, they show their summaries

Cypress test results summaries

You can find this code in the branch ci of the repo bahmutov/arrowjs-test-examples.

Components with CSS

Let's grab another ArrowJS example. Let's take the Dropdown component, it looks really nice in its demo

Several dropdowns demo

src/dropdown.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { reactive, html } from '@arrow-js/core'

export function dropdown(items) {
const state = reactive({
isOpen: false,
selection: items[0],
})

return html` <div
class="dropdown"
@click="${() => {
state.isOpen = !state.isOpen
}}"
>
<ul class="dropdown-list" data-is-open="${() => state.isOpen}">
${() =>
items.map(
(item) =>
html` <li
data-selected="${() => item === state.selection}"
@click="${() => {
state.selection = item
}}"
>
${item}
</li>`,
)}
</ul>
</div>`
}

Let's make a sample test.

src/dropdown.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { dropdown } from './dropdown'

it('dropdown', { viewportWidth: 200, viewportHeight: 200 }, () => {
const planets = [
'Mercury',
'Venus',
'Earth',
'Mars',
'Jupiter',
'Saturn',
'Uranus',
'Neptune',
]

cy.mount(dropdown(planets))
cy.contains('[data-selected]', 'Mercury').should('be.visible').click()
cy.contains('.dropdown-list li', 'Jupiter').click()
cy.contains('[data-selected]', 'Jupiter').should('be.visible')
cy.contains('Mercury').should('not.be.visible')
})

Ughh, the test fails. And our dropdown looks nothing like its demo!

The failing dropdown test

Hmm, if our component does not look like the real app, then we probably forgot to include its CSS styles. The demo site has dropdown.css link.

The demo page HTML includes dropdown CSS

Let's grab this CSS file and simply import it from our spec file src/dropdown.cy.js - Vite should be able to bundle it correctly. It is pretty fun to watch. The component suddenly becomes "real", and the test passes.

For full experience, let's include the demo page index.css too. It will make the next test even better.

A pyramid of components

Cypress component testing can deal with the smallest components (and even unit tests), and large components that include the entire pyramid of sub components. There is no shallow rendering - it is all running live in the browser. Let's test the entire demo page showing those three dropdowns.

src/dropdown.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { dropdown } from './dropdown'
import './index.css'
import './dropdown.css'
import { html } from '@arrow-js/core'

it('dropdown', { viewportWidth: 200, viewportHeight: 200 }, () => {
...
})

it(
'shows three dropdowns',
{ viewportHeight: 300, viewportHeight: 1000 },
() => {
const planets = [
'Mercury',
'Venus',
'Earth',
'Mars',
'Jupiter',
'Saturn',
'Uranus',
'Neptune',
]

const rivers = ['Amazon', 'Danube', 'Mississippi', 'Nile', 'Yangtze']

const cities = [
'Atlanta',
'Berlin',
'London',
'Los Angeles',
'Moscow',
'New York',
'Rome',
]

cy.mount(
html` <ul class="dropdown-demo">
<li>${dropdown(planets)}</li>
<li>${dropdown(rivers)}</li>
<li>${dropdown(cities)}</li>
</ul>`,
)
cy.log('**Mercury is selected first**')
cy.get('.dropdown-demo .dropdown')
.first()
.find('li[data-selected=true]', 'Mercury')
.should('be.visible')
cy.log('**open river dropdown')
cy.get('.dropdown-demo .dropdown')
.eq(1)
.click()
.find('.dropdown-list')
.should('have.attr', 'data-is-open', 'true')
.contains('li', 'Nile')
.should('be.visible')
},
)

We can run the test "shows three dropdowns" which is pretty much tests the full demo page https://www.arrow-js.com/demos/dropdowns.html but without the actual page.

Testing three dropdowns component

Nice.

You can find all source in the main branch of the repo bahmutov/arrowjs-test-examples.

See also