How to Publish Custom Cypress Command on NPM

How to test, transpile, and publish an NPM module with a custom Cypress command.

In my previous blog post Writing a Custom Cypress Command I have shown a simple custom Cypress custom command for finding a form element by its label. In this blog post I will show how to correctly publish this custom command to the NPM registry.

🔎 You can find the source code for this blog post in bahmutov/cypress-get-by-label repo.

The command

I usually start by developing the code in-place and writing the custom command in the spec file. Given an HTML file:

cypress/index.html
1
2
3
4
5
6
7
8
<body>
<form>
<label for="fname">First name:</label><br />
<input type="text" id="fname" name="fname" /><br />
<label for="lname">Last name:</label><br />
<input type="text" id="lname" name="lname" />
</form>
</body>

The following spec file tests the custom command

cypress/integration/spec.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
// enables intelligent code completion for Cypress commands
// https://on.cypress.io/intelligent-code-completion
/// <reference types="cypress" />

Cypress.Commands.add('getByLabel', (label) => {
cy.log('**getByLabel**')
cy.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
})

describe('cypress-get-by-label', () => {
it('find the elements', () => {
// path with respect to the root folder
cy.visit('cypress/index.html')
cy.getByLabel('First name:').should('have.value', '').type('Joe')
cy.getByLabel('First name:').should('have.value', 'Joe')
cy.getByLabel('Last name:').type('Smith')
// check the form inputs
cy.get('form')
.invoke('serializeArray')
.should('deep.equal', [
{ name: 'fname', value: 'Joe' },
{ name: 'lname', value: 'Smith' },
])
})
})

The spec passes.

The test find the form elements by label

Continuous integration

As soon as we have the first test, I set up continuous integration pipeline. I can pick amongst various CI providers, but perhaps GitHub Actions is the simplest to set up right now. We can run Cypress via cypress-io/github-action with ease:

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
name: ci
on: [push]
jobs:
cypress-run:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/[email protected]
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/[email protected]

The test passes on CI.

The test passes on GitHub Actions

Command source refactoring

Now let's refactor our command to be usable from any spec. Our users should be able to register the custom command in a particular spec or in all specs by importing the command from their support file. Let's move the command source code into its own file src/index.js

src/index.js
1
2
3
4
5
6
7
8
Cypress.Commands.add('getByLabel', (label) => {
cy.log('**getByLabel**')
cy.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
})

The spec file requires the src/index.js file

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require('../../src')

describe('cypress-get-by-label', () => {
it('find the elements', () => {
// path with respect to the root folder
cy.visit('cypress/index.html')
cy.getByLabel('First name:').should('have.value', '').type('Joe')
cy.getByLabel('First name:').should('have.value', 'Joe')
cy.getByLabel('Last name:').type('Smith')
// check the form inputs
cy.get('form')
.invoke('serializeArray')
.should('deep.equal', [
{ name: 'fname', value: 'Joe' },
{ name: 'lname', value: 'Smith' },
])
})
})

Great, but when we distribute this NPM package, our users will require it by name. Thus instead of require('../../src/index') let's require the top level package folder. We can set the main field to point at the src/index in our package.json file. While we are there, let's set the list of files to be published to NPM, our users only will need the src folder.

package.json
1
2
3
4
5
6
7
8
{
"name": "cypress-get-by-label",
"description": "Example custom Cypress command finding form element by its label",
"main": "src",
"files": [
"src"
]
}
cypress/integration/spec.js
1
2
3
4
5
require('../..')

describe('cypress-get-by-label', () => {
...
})

Time to publish!

Publishing to NPM

Let's verify the files we are about to publish to NPM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ npm pack --dry
npm notice
npm notice 📦 [email protected]
npm notice === Tarball Contents ===
npm notice 185B src/index.js
npm notice 782B package.json
npm notice 287B README.md
npm notice === Tarball Details ===
npm notice name: cypress-get-by-label
npm notice version: 1.0.0
npm notice filename: cypress-get-by-label-1.0.0.tgz
npm notice package size: 705 B
npm notice unpacked size: 1.3 kB
npm notice shasum: a2778f2e23a84580a4e5a49e70afcee6a9fdbda1
npm notice integrity: sha512-UzYZi4A7i0lv6[...]ePqI7ukEkzgUQ==
npm notice total files: 3
npm notice
cypress-get-by-label-1.0.0.tgz

So we are going to include three files in the distributable archive: the README file, the package.json, and src/index.js. Seems right.

As always I will use semantic-release following How I publish to NPM approach. Since GitHub Actions will have GH token set, we can grab a new NPM token using the command:

1
2
3
4
5
6
7
8
9
10
11
$ semantic-release-cli setup --gh-token foo
? What is your npm registry? https://registry.npmjs.org/
? What is your npm username? bahmutov
? What is your npm password? [hidden]
? What is your NPM two-factor authentication code? 520164
? What CI are you using? Other (prints tokens)

----------------------------------------------
GH_TOKEN=foo
NPM_TOKEN=*******
----------------------------------------------

Set the NPM_TOKEN as a secret when running the GitHub Action

Set the new NPM token as action secret

To do semantic release, I like using cycjimmy/semantic-release-action. The ci.yml file is now:

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: ci
on: [push]
jobs:
cypress-run:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/[email protected]
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/[email protected]
- name: Semantic Release
uses: cycjimmy/[email protected]
id: semantic
with:
branch: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

In order to actually release, let's update our README file. We need to explain how to use this package and commit with a feature or fix release.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: .github/workflows/ci.yml
modified: README.md
modified: package-lock.json
modified: package.json

$ git commit -m "feat: publish command"
[main ec8ae51] feat: publish command
4 files changed, 5921 insertions(+), 85 deletions(-)

$ git push

The GitHub action runs and publishes "cypress-get-by-label v1.0.0", which we can find at www.npmjs.com/package/cypress-get-by-label.

Better registration

Currently we have published our command that registers itself immediately whenever some runs require('cypress-get-by-label') code. What if we want to customize the custom command? What if we want to register it under a different name? It makes sense to export a registration function, instead of immediately modifying the global state by running Cypress.Commands.add('getByLabel', ...) function.

Let's modify our package.

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const registerCommand = (name = 'getByLabel') => {
const getByCommand = (label) => {
cy.log(`**${name}**`)
cy.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
}

Cypress.Commands.add(name, getByCommand)
}

module.exports = {
registerCommand,
}

Let's update our spec file to test this:

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { registerCommand } = require('../..')
registerCommand()
// or we could register under a different name
registerCommand('getFormField')

describe('cypress-get-by-label', () => {
it('find the elements', () => {
// path with respect to the root folder
cy.visit('cypress/index.html')
cy.getByLabel('First name:').should('have.value', '').type('Joe')
cy.getByLabel('First name:').should('have.value', 'Joe')
// try alternative command name
cy.getFormField('Last name:').type('Smith')
// check the form inputs
cy.get('form')
.invoke('serializeArray')
.should('deep.equal', [
{ name: 'fname', value: 'Joe' },
{ name: 'lname', value: 'Smith' },
])
})
})

The commands work, notice both "getByLabel" and "getFormField" log messages.

Using the command under two different names

Because we have changed the public API of our package, we have to publish this change as a breaking change. Since we use the semantic versioning that analyzes the commit messages, we can simply do:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git commit
// type message

$ git show
commit a5064eea678afc698cd8cd4aad9a0edf58c035a8 (HEAD -> main)
Author: Gleb Bahmutov <[email protected]>
Date: Thu Jan 21 15:43:09 2021 -0500

feat: expose command registration function

BREAKING CHANGE: need to register the command under a name

$ git push

Semantic release publishes v2

cypress-get-by-label v2 release

Oops, we forgot to update the README when we published the new release.

## use

Include from your Cypress support file or individual spec

    const { registerCommand } = require('cypress-get-by-label')
    registerCommand()
    // or we could register under a different name
    registerCommand('getFormField')

Then use the command `cy.getByLabel` (default) or the custom name

    // if used registerCommand()
    cy.getByLabel('First name:')
    // if used registerCommand('getFormField')
    cy.getFormField('First name:')

We should commit this change with fix: ... prefix. For example

1
$ git commit -m "fix: update readme for v2"

Which publishes v2.0.1 of the package.

Using an example

At this point I like creating a separate repo to show the custom command in action and to verify everything works when a user tries to install and use cypress-use-by-label. You can find my example in the repo bahmutov/cypress-get-by-label-example.

When installing the new package, I recommend following the README file, just to "test" the installation instructions.

1
2
$ npm i -D cypress-get-by-label
+ [email protected]

In the spec file (I have copied the spec and example from cypress-get-by-label) I will use the name of the package.

cypress/integration/spec.js
1
2
3
4
// cypress-get-by-label-example
const { registerCommand } = require('cypress-get-by-label')
registerCommand()
...

The example works. We can set up the CI, RenovateBot, and even self-updating README badges to keep this example repo up-to-date.

cypress-get-by-label-example readme with badges

I then link the new repo from the cypress-get-by-label README file

Types

When the user uses the cy.getByLabel command, there is no IntelliSense information, unlike cy.visit and other built-in Cypress commands.

cy.getByLabel method has no type information

In order to describe the types for the default cy.getByLabel command we need to include types file with our NPM package.

Note: we are only going to provide type for the default cy.getByLabel command, and we also assume command has been registered by the user's test code. If the user uses a different command name, they could write a similar typescript file locally.

Let's follow Cypress TypeScript guide and add src/index.d.ts file

src/index.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// load type definitions that come with Cypress module
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
/**
* Finds a form element using the label's text
* @param label string
* @example
* cy.getByLabel('First name:').type('Joe')
*/
getByLabel(label: string): Chainable<Element>
}
}

In the package.json file add the "types" field pointing at the src/index.d.ts file

package.json
1
2
3
{
"types": "src"
}

Publish the new version of the package - it is a new feature, so it becomes v2.1.0, and the example project should use the types by loading them like this:

cy.getByLabel method has the right types

ES6 import and export

If we register the custom command, and try to avoid modifying the global state using require, we might as well declare our registration function using the ES6 export keyword.

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
export const registerCommand = (name = 'getByLabel') => {
const getByCommand = (label) => {
cy.log(`**${name}**`)
cy.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
}

Cypress.Commands.add(name, getByCommand)
}

The cypress-get-by-label project still works like before. Let's publish our change, it becomes v2.2.0.

Let's upgrade cypress-get-by-label-example to use v2.2.0 - all working the same. The top-level export keyword did not affect the project. We can also replace the require with import keyword in the user project

cypress/integration/spec.js
1
2
// const { registerCommand } = require('cypress-get-by-label')
import { registerCommand } from 'cypress-get-by-label'

Even if we refactor the internals of cypress-get-by-label to use import and export keywords, the user package should be able to bundle them correctly using Cypress v6+.