Write Cypress Markdown Preprocessor

How to write a file preprocessor for bundling Cypress specs

The idea

Wouldn't it be cool to write a more descriptive tests? Tests that talk about what is going on? Tests that include more context, more explanation, more images? What if the tests were written in Markdown and just embedded the code blocks to be executed?

A good example is bahmutov/cypress-examples where the same Markdown files have the tests and also become the static HTML pages that you can see at https://glebbahmutov.com/cypress-examples/. Here is the start of the Location.md file with a Cypress test

Location.md with cy.hash test

Using VuePress we can convert the Markdown file into a static page, shown deployed here

Location tests page

📚 Another use for Markdown files is to generate application demos, read the Cypress Book blog post.

Markdown preprocessor

If we plan to convert a Markdown file into a bundled JavaScript the browser can execute, we need a Markdown file preprocessor. The Cypress preprocessor is literally just a bundler - it receives the source filename and responds with a filename of the JavaScript bundle. The Test Runner then loads the bundle and runs the tests.

Tip: by default Cypress only expects .js and .ts spec files. If we want to bundle Markdown files, in the cypress.json set the "testFiles": "*.md" property.

Let's write one.

🧭 You can find the Markdown preprocessor in bahmutov/cypress-markdown-preprocessor repo.

The Cypress plugin file will register the preprocessor

cypress/plugins/index.js
1
2
3
4
5
const mdPreprocessor = require('cypress-markdown-preprocessor')
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
on('file:preprocessor', mdPreprocessor)
}

Great, it is a start. Now let's fill the placeholder code - it will simply print how Cypress calls the preprocessor.

1
2
3
4
5
6
7
8
9
const fs = require('fs')
function mdPreprocessor(file) {
const { filePath, outputPath, shouldWatch } = file
console.log({ filePath, outputPath, shouldWatch })
// we need to output something
fs.writeFileSync(outputPath, '', 'utf8')
return outputPath
}
module.exports = mdPreprocessor

When we run Cypress in the interactive mode the shouldWatch is set to true, but for now let's solve the simple problem of bundling a file once for the cypress run mode.

If we call our project now, it shows the filenames of the two bundled files: the support file and the spec file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ npx cypress run
...
Running: spec.md (1 of 1)
{
filePath: '/Users/gleb/git/cypress-markdown-preprocessor/cypress/integration/spec.md',
outputPath: '/Users/gleb/Library/Application Support/Cypress/cy/production/projects/cypress-markdown-preprocessor-bb8141ea63a521b0f99b838f2338a5b3/bundles/cypress/integration/spec.md',
shouldWatch: false
}
{
filePath: '/Users/gleb/git/cypress-markdown-preprocessor/cypress/support/index.js',
outputPath: '/Users/gleb/Library/Application Support/Cypress/cy/production/projects/cypress-markdown-preprocessor-bb8141ea63a521b0f99b838f2338a5b3/bundles/cypress/support/index.js',
shouldWatch: false
}


0 passing (1ms)

Because our preprocessor simply has written an empty output string, Cypress runs zero tests. We need to generate actual JavaScript tests from the Markdown text. We can use some special syntax to mark the test blocks. For example, I use fiddle and fiddle-end comments with html and js code blocks. Each block becomes an "app" and the test by using cypress-fiddle module.

Example code block we want to extract from the Markdown file

Bundle JS file

What happens if we wanted to bundle a JavaScript file? We would use one of the Cypress' preprocessors, like @cypress/browserify-preprocessor. Our file could check the input file extension and direct the JS file bundling to that preprocessor like this:

1
2
3
4
5
6
7
8
const cyBrowserify = require('@cypress/browserify-preprocessor')()
const mdPreprocessor = (file) => {
const { filePath, outputPath, shouldWatch } = file

if (filePath.endsWith('.js')) {
return cyBrowserify(file)
}
}

If we extract the test blocks from Markdown text, we can call the cyBrowserify too:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bundleMdFile = (filePath, outputPath) => {
const md = fs.readFileSync(filePath, 'utf8')
// extract tests into variable "specSource"

const writtenTempFilename = tempWrite.sync(
specSource,
path.basename(filePath) + '.js',
)

return cyBrowserify({
filePath: writtenTempFilename,
outputPath,
// since the file is generated once, no need to watch it
shouldWatch: false,
on: () => {},
})
}

The above function bundleMdFile is our workhorse - we can call it anytime we need to bundle a Markdown file. The function cyBrowserify returns a promise, which will resolve when the full bundle is written to the outputPath file.

Promises

Speaking of promises - what happens if multiple spec files are bundled? Well, every spec file is bundled, but also the support file needs to be bundled first. For every spec file. Of course we do not want to repeat this work - we want to bundle every spec once. Thus a good pattern is to save the bundling promises in an object as a local cache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const bundleMdFile = (filePath, outputPath) => { ... }

// bundled[filename] => promise
const bundled = {}
const mdPreprocessor = (file) => {
const { filePath, outputPath, shouldWatch } = file

if (filePath.endsWith('.js')) {
return cyBrowserify(file)
}

if (bundled[filePath]) {
// we have the bundle in progress or finished
return bundled[filePath]
}

bundled[filePath] = bundleMdFile(filePath, outputPath)
return bundled[filePath]
}

The first time we need to bundle the support file, we get a promise, which eventually resolves. The Test Runner grabs the output file and serves it. Whenever the support file needs to be bundled again (which happens when the second spec is running), the Test Runner is asking for the same file name, and immediately gets the promise from the bundled[filePath] value.

shouldWatch

During the interactive test execution with cypress open, the Test Runner should watch the source files. If the user edits and saves the spec, the tests should re-run, which means the preprocessor bundles the spec file again. This is how it can be done for a Markdown file:

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
const chokidar = require('chokidar')
const mdPreprocessor = (file) => {
...
if (shouldWatch) {
// start bundling the first time
bundled[filePath] = bundleMdFile(filePath, outputPath)

// and start watching the input Markdown file
const watcher = chokidar.watch(filePath)
watcher.on('change', () => {
// if the Markdown file changes, we want to rebundle it
// and tell the Test Runner to run the tests again
bundled[filePath] = bundleMdFile(filePath, outputPath)
bundled[filePath].then(() => {
file.emit('rerun')
})
})

// when the test runner closes this spec
file.on('close', () => {
delete bundled[filePath]
watcher.close()
})

return bundled[filePath]
}

// non-interactive mode
bundled[filePath] = bundleMdFile(filePath, outputPath)
return bundled[filePath]
}

In the interactive mode we create a Chokidar file watcher. Every time the file changes, we rebundle it and emit the Cypress "rerun" event. The Cypress file object we receive is an event emitter. The same object also emits the close event when the Test Runner closes the window and stops running a particular spec. In that case we close the file watcher.

The result

Our Markdown preprocessor is shown in action - as I edit the source file the Test Runner re-runs the updated test.

Cypress runs the tests from a Markdown file

Now you can use what this blog post has shown to write your own file preprocessor.

PS: I have also written @bahmutov/cy-rollup which is a Cypress Rollup file preprocessor.