Cypress WASM Example

How to load and test WASM code using Webpack and test it using Cypress

Imagine you are writing code in some language, like Rust and want to run it in the browser. You can compile it into WASM module, but how do you test this module in an actual browser? In this blog post I will show you how.

🧭 Find the source code for this post in bahmutov/cypress-example-wasm-ts repository forked from repo linked the original issue #8804

The application

Let's compile a simple Rust sum function.

src/lib.rs
1
2
3
4
#[wasm_bindgen]
pub fn sum(a: f64, b: f64) -> f64 {
a + b
}

We can use command wasm-pack build --out-dir internal via wasm-pack which gives us several files in the internal folder

1
2
3
4
5
6
7
8
9
10
11
12
$ ls -la internal
total 64
drwxr-xr-x 10 gleb staff 320 Oct 9 14:04 .
drwxr-xr-x 26 gleb staff 832 Oct 10 09:23 ..
-rw-r--r-- 1 gleb staff 1 Oct 9 14:04 .gitignore
-rw-r--r-- 1 gleb staff 566 Oct 9 14:01 README.md
-rw-r--r-- 1 gleb staff 341 Oct 9 14:04 package.json
-rw-r--r-- 1 gleb staff 160 Oct 9 14:04 wasm_pack_ts_cypress.d.ts
-rw-r--r-- 1 gleb staff 101 Oct 9 14:04 wasm_pack_ts_cypress.js
-rw-r--r-- 1 gleb staff 201 Oct 9 14:04 wasm_pack_ts_cypress_bg.js
-rw-r--r-- 1 gleb staff 180 Oct 9 14:04 wasm_pack_ts_cypress_bg.wasm
-rw-r--r-- 1 gleb staff 134 Oct 9 14:04 wasm_pack_ts_cypress_bg.wasm.d.ts

Now we just need to load the internal/wasm_pack_ts_cypress module into our JavaScript code.

Webpack

Let's try importing this module directly using Webpack bundler. I will have src/index.js as an entry point:

src/index.js
1
import { sum } from '../internal/wasm_pack_ts_cypress'

Unfortunately running npx webpack in this case fails with an error

1
2
3
4
ERROR in ./internal/wasm_pack_ts_cypress_bg.wasm
WebAssembly module is included in initial chunk.
This is not allowed, because WebAssembly download and compilation must happen asynchronous.
Add an async splitpoint (i. e. import()) somewhere between your entrypoint and the WebAssembly module

Webpack cannot statically load the .wasm file to provide the import. It is an asynchronous operation. Thus we have to use a dynamic import to load the WASM code.

src/index.js
1
2
3
4
5
import('../internal/wasm_pack_ts_cypress').then(({ sum }) => {
// expose the "sum" import on the window object
// to be able to access it from the Cypress tests
window.sum = sum
})

or we can set all exported methods using the module name

src/index.js
1
2
3
4
import('../internal/wasm_pack_ts_cypress').then((exports) => {
// probably use the module name as key
window['../internal/wasm_pack_ts_cypress'] = exports
})

For simplicity I prefer the window.sum = sum way.

Let's run the bundler and see the results

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ npx webpack
Hash: 03ed532ac20ff6a13948
Version: webpack 4.44.2
Time: 142ms
Built at: 10/10/2020 12:26:39 PM
Asset Size Chunks Chunk Names
0.js 241 bytes 0 [emitted]
81334c6f545380fcc5a4.module.wasm 180 bytes 0 [emitted] [immutable]
main.js 2.82 KiB 1 [emitted] main
Entrypoint main = main.js
[0] ./internal/wasm_pack_ts_cypress.js + 1 modules 307 bytes {0} [built]
| ./internal/wasm_pack_ts_cypress.js 101 bytes [built]
| ./internal/wasm_pack_ts_cypress_bg.js 201 bytes [built]
[1] ./src/index.js 988 bytes {1} [built]
[2] ./internal/wasm_pack_ts_cypress_bg.wasm 180 bytes {0} [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value.
Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior.
Learn more: https://webpack.js.org/configuration/mode/

The dist folder has several files

1
2
3
4
5
6
7
8
$ ls -la dist
total 48
drwxr-xr-x 8 gleb staff 256 Oct 10 09:32 .
drwxr-xr-x 26 gleb staff 832 Oct 10 09:23 ..
-rw-r--r-- 1 gleb staff 241 Oct 10 12:26 0.js
-rw-r--r-- 1 gleb staff 180 Oct 10 12:26 81334c6f545380fcc5a4.module.wasm
-rw-r--r--@ 1 gleb staff 163 Oct 10 09:06 index.html
-rw-r--r-- 1 gleb staff 2887 Oct 10 12:26 main.js

The file dist/index.html is ours - it simply imports the main.js bundle produced by Webpack

dist/index.html
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
<title>Wasm Example</title>
</head>
<body>
<h1>Wasm example</h1>
<script src="main.js"></script>
</body>
</html>

The page

Now we have a bundle and a .wasm file, let's see the page. We need to have a static server, we cannot simply load file://dist/index.html file. I will use serve

1
2
3
4
5
6
7
8
9
10
11
$ npx serve dist
┌───────────────────────────────────────────────┐
│ │
│ Serving! │
│ │
│ - Local: http://localhost:5000 │
│ - On Your Network: http://10.0.0.6:5000 │
│ │
│ Copied local address to clipboard! │
│ │
└───────────────────────────────────────────────┘

If you open the browser at localhost:5000 you will see the window.sum working! Notice the .wasm file fetched using a separate request.

Local WASM code working in the browser

The Cypress test

Now that the page with WASM code is working, let's test it using Cypress. First, we probably want to run Cypress tests after starting the server, so I will add start-server-and-test utility.

1
2
3
$ npm i -D cypress start-server-and-test
+ [email protected]
+ [email protected]

Let's add a couple of NPM script commands

package.json
1
2
3
4
5
6
7
8
{
"scripts": {
"test": "cypress open",
"build": "webpack",
"start": "serve dist",
"dev": "start-test 5000"
}
}

The command npm run dev will use the default npm start command to run the server, wait for local port localhost:5000 to respond, then will execute npm test to start Cypress. Our test simply visits the localhost:5000 page (I recommend setting it as baseUrl in the cypress.json file).

sum.spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe('Wasm', () => {
it('sums', () => {
cy.visit('/')
// automatically retry checking "window.sum" until
// it is set and is a function
cy.window()
.its('sum')
.should('be.a', 'function')
.then((sum) => {
// now let's test the sum
expect(sum(2, 3)).to.equal(5)
})
})
})

The test runs and verifies the WASM function sum is working as expected.

Calling sum to test it

Great.

Direct import

As a last note: trying to directly import the WASM module from Cypress test (as a unit test) does NOT work

sum-import.test.ts
1
2
3
4
5
6
7
import { sum } from '../internal/wasm_pack_ts_cypress'

describe('sum test', function () {
it('should be able to run a sum test', async () => {
expect(sum(1, 2)).to.equal(3)
})
})

You will get exactly the same Webpack error as you saw earlier - because Cypress v5 uses Webpack under the hood to bundle specs.

Direct WASM import into Cypress fails

Dynamic imports in Cypress specs do not work also - because Cypress Webpack preprocessor assumes the bundle produced is a single JS file, and does not currently serve multiple resources. Maybe in the future we will remove this limitation and WASM code could be tested directly like this:

future.test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// JUST AN EXAMPLE
// this is just an example of what a future test would look like

describe('sum test', () => {
it('sums numbers', () => {
cy.wrap(import('../internal/wasm_pack_ts_cypress')) // wraps import Promise
.its('sum')
.then((sum) => {
// now let's test the sum
expect(sum(2, 3)).to.equal(5)
})
})
})

Follow Cypress and my work @bahmutov to learn if this becomes possible.