Spy On Clipboard Copy Method Call

Cypress tests can stub problematic browser API method calls.

Let's take an example application that copies some text to the system clipboard when the user clicks a button.

Copy items to the clipboard button

🎁 You can find the source code for this blog post in the repo bahmutov/test-api-example.

The application code for that button formats the string and calls the clipboard.writeText browser API method.

app.js
1
2
3
4
5
6
7
8
9
10
async copyTodos({ state }) {
const markdown =
state.todos
.map((todo) => {
const mark = todo.completed ? 'x' : ' '
return `- [${mark}] ${todo.title}`
})
.join('\n') + '\n'
await navigator.clipboard.writeText(markdown)
}

The clipboard.writeText is a security-restricted method. To prevent malicious JavaScript from stealing your information, sending a synthetic "Click" event to the button for example fails if the browser window does not have focus (meaning the user did not click it).

Synthetic click event fails

Calling the clipboard.writeText method is the problem. We can stub it from a Cypress test using the included cy.stub command.

1
2
3
4
5
6
7
8
9
10
cy.window()
.its('navigator.clipboard')
.then((clipboard) => {
cy.stub(clipboard, 'writeText').as('writeText')
})
cy.get('[title="Copy todos to clipboard"]').click()
cy.get('@writeText').should(
'have.been.calledOnceWith',
Cypress.sinon.match.string,
)

Now the test runs correctly in all situations.

Stubbed clipboard.writeText method

By checking if the clipboard.writeText method was really called, our test can catch any drastic changes to the application.

Resolve the promise

The clipboard.writeText method definition returns a Promise<void>. The application code might attach a .catch(err) callback to that promise. Thus the test code needs to return a Promise instance. We can do this using the Sinon helper .resolve()

1
cy.stub(clipboard, 'writeText').as('writeText').resolves()

Update 1: document.execCommand

Modern applications sometimes use an older deprecated method of copying text into the clipboard via document.execCommand. This command is really cumbersome to use. For example, to copy text to the clipboard we have to create a hidden text area, put the text there, select it, and then execute the command. Then we need to remove the text area element:

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
// app.js
// copy to clipboard code from
// https://github.com/angular/components
function copyToClipboard(text) {
const textarea = document.createElement('textarea')
const styles = textarea.style

// Hide the element for display and accessibility. Set a fixed position so the page layout
// isn't affected. We use `fixed` with `top: 0`, because focus is moved into the textarea
// for a split second and if it's off-screen, some browsers will attempt to scroll it into view.
styles.position = 'fixed'
styles.top = styles.opacity = '0'
styles.left = '-999em'
textarea.setAttribute('aria-hidden', 'true')
textarea.value = text
// Making the textarea `readonly` prevents the screen from jumping on iOS Safari (see #25169).
textarea.readOnly = true
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
successful = document.execCommand('copy')
textarea.remove()
}

copyTodos({ state }) {
const markdown =
state.todos
.map((todo) => {
const mark = todo.completed ? 'x' : ' '
return `- [${mark}] ${todo.title}`
})
.join('\n') + '\n'
copyToClipboard(markdown)
}

Our test might spy / stub the document.execCommand method when called with the copy parameter.

1
2
3
4
5
6
7
// cypress/e2e/clipboard.cy.ts
cy.document().then((doc) => {
cy.stub(doc, 'execCommand')
.withArgs('copy')
.as('writeText')
.returns(true)
})

But how do we get the copied text? Well, assuming you want to use the deprecated method, need the copied text, and do not want to access the real system clipboard, you could simply pass the text when calling the method.

1
2
3
4
// app.js
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
successful = document.execCommand('copy', false, text)

Let's check the text

1
2
3
4
5
6
7
8
9
10
11
12
// cypress/e2e/clipboard.cy.ts
cy.get('[title="Copy todos to clipboard"]').click()
cy.get('@writeText')
.should(
'have.been.calledOnceWith',
'copy',
false,
Cypress.sinon.match.string,
)
.its('firstCall.args.2')
.should('include', 'write code')
.and('include', 'write tests')

Testing document.execCommand copy command

See also