Testing browser's clipboard with jest
Tags: webdev, javascript, jest, testing
Published: 2023-06-17 15:09:01

Recently I have been playing around react + jest trying to sharp my skills on outside-in TDD and the idea behind the setup is to give an easy way to start testing. The application though, was a simple one, it just formatted json string, but it has some interesting things, for example, accessing the clipboard to read and write.

Such thing seems to be trivial when testing, so some kind of
test double is required to be in place, and it turns out that it is indeed, easy to deal with that, but the testing code becomes a bit verbose.

In this post I am going to try to depict the problems I had around mocking the clipboard API what solution I put in place.

## A bit of context

Copying/paste things in and out from the browser have been done through a specific command `document.execCommand()`. Therefore, as depicted by Jason Miller and Thomas Steiner, it has an important drawback: [execCommand is synchronous](https://web.dev/async-clipboard).

Such a behavior leads to poor experience from the user perspective leading to hanging the page until it is completed. For that reason, the async API was built.

The new API allow developers to create applications that does not block while operating on the clipboard for reading or writing. With the new API a new behavior came into play:

now the user has the power to allow or block the interaction with the clipboard - which was previously not possible with the *execCommand*.

## The browser API

The first thing to depict here is the clipboard API that the browser has to allow read/write from the transfer area. The permissions happens automatically.

## The solution

Developing an application that interacts with the new clipboard API is not problem, there are different examples on the internet and the blog post by [web.dev](https://web.dev/async-clipboard) is one of those.

The challenge comes when we want to test the interactions that should happen when implementing the code that will read/write in the clipboard.

Given this scenario the plugin [jest-clipboard](https://github.com/marabesi/jest-clipboard) was created, it enables developers to set up the clipboard state and focus on the expected behaviour instead of details of mocking the clipboard API.

### Handling text

Let's start with a scenario which we need to read a text that the user has in the transfer area.

In that case, the first thing to use jest-clipboard is to set the plugin in the before each and after each hook functions. This will enable jest-clipboard to set correctly the scenario for each test in the suite:

```typescript
import { setUpClipboard, tearDownClipboard } from 'jest-clipboard';

describe('My test', () => {
  beforeEach(() => {
    setUpClipboard();
  });

  afterEach(() => {
    tearDownClipboard();
  });
});
```

The next step is to write the test and start asserting with the clipboard. For example, to set a text in the clipboard transfer are the test would be something like the following:

```typescript
import { setUpClipboard, tearDownClipboard, writeTextToClipboard, readTextFromClipboard } from 'jest-clipboard';

describe('My test', () => {
  beforeEach(() => {
    setUpClipboard();
  });

  afterEach(() => {
    tearDownClipboard();
  });

   it('should use text from clipboard', async () => {
      await writeTextToClipboard('I want this to be in the transfer area');

      // in here the production code needs to be used

      expect(await readTextFromClipboard()).toEqual('I want this to be in the transfer area');
  });
});
```

Another example that we might face is to send some information we have to the clipboard, for that, the clipboard API has the writeText functionality, let's have a look on how it would be:

```typescript
import { setUpClipboard, tearDownClipboard, writeTextToClipboard, readTextFromClipboard } from 'jest-clipboard';

describe('My test', () => {
  beforeEach(() => {
    setUpClipboard();
  });

  afterEach(() => {
    tearDownClipboard();
  });

   it('should use text from clipboard', async () => {
      // no need to write something to the clipboard first, the setup/teardown make sure to give
      // a clean state for every test.

      // in here the production code needs to be used, given an example that
      // the production code writes 'I want this to be in the transfer area'
      // this text would be there.

      // Thus, the assertion would be the same as the previous example.

      expect(await readTextFromClipboard()).toEqual('I want this to be in the transfer area');
  });
});
```

The clipboard was design to support multiple types of media and for that end it was split into the text that we just saw and the other things (that could an image for example).

Handling other media types follows basically the same process, let's see how it is in the next section.

### Handling other things

The set up for out clipboard keeps the same, no change is required, the bit of piece that requires change is the method we invoke to write and read from the clipboard.

Let's take out same example that we used to writing/reading text from the clipboard and transform it into the one that uses the read/write API from the clipboard.

The first thing to note is that now, we are starting to handle [ClipboardItems](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem) and [Blobs](https://developer.mozilla.org/en-US/docs/Web/API/Blob) instead of raw strings:

```typescript
describe('My test', () => {
  beforeEach(() => {
    setUpClipboard();
  });

  afterEach(() => {
    tearDownClipboard();
  });

  it('write to clipboard (write)', async () => {
    // this is now a blob and not a text anymore
    // here we need to specify the type of media
    const blob = new Blob(['my text'], { type: 'text/plain' });

    const clipboardItem: ClipboardItem = {
      presentationStyle: 'inline',
      types: ['plain/text'],
      getType(type: string): Promise<Blob> {
        return new Promise((resolve) => {
          resolve(blob)
        });
      }
    };

    const clipboardItems: ClipboardItems = [clipboardItem]
    await writeItemsToClipboard(clipboardItems);

    const items = await readFromClipboard();

    const type1 = await items[0].getType(imagePng);
    expect(await type1.text()).toBe('my text');
  });
});
```

Let's pretend that we want to handle an image that the user will copy from the browser and paste into our application
(like google docs does or notion does).

There are a few steps before doing that, let's enumerate them:

1. We need an image to use as an example
2. We will convert the image into a blob
3. Assert the file contents or other information

Reading another media type from the clipboard would be like the following:

```typescript
describe('My test', () => {
  beforeEach(() => {
    setUpClipboard();
  });

  afterEach(() => {
    tearDownClipboard();
  });

  it('write to clipboard (write)', async () => {
    // this is the first items, we are creating an image from a base64 string
    // and we are also creating a blob from that
    const imagePng = 'image/png';
    const base64Image ='iVBORw0KGgoAAAANSUhEUgAAAEYAAABACAIAAAAoFZbOAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAaElEQVRoge3PQQ3AIADAQMAQD4J/azOxZOlyp6Cd+9zxL+vrgPdZKrBUYKnAUoGlAksFlgosFVgqsFRgqcBSgaUCSwWWCiwVWCqwVGCpwFKBpQJLBZYKLBVYKrBUYKnAUoGlAksFlgoeg2ABFxfCv1QAAAAASUVORK5CYII='
    const buffer = Buffer.from(base64Image, 'base64');
    const blob = new Blob([buffer]);

    // refers to https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/web_tests/external/wpt/clipboard-apis/async-promise-write-blobs-read-blobs.https.html
    const clipboardItem: ClipboardItem = {
      presentationStyle: 'inline',
      types: [imagePng],
      getType(type: string): Promise<Blob> {
        return new Promise((resolve) => {
          resolve(blob)
        });
      }
    };

    // finally we can grab what the clipboard has
    const clipboardItems: ClipboardItems = [clipboardItem]
    await writeItemsToClipboard(clipboardItems);

    const items = await readFromClipboard();

    const type1 = await items[0].getType(imagePng);

    // the assertion here is with the size as the text would give a bibary encoded
    expect(type1.size).toBe(182);
  });
});
```

The clipboard API handles different media types based on the blob interface and the clipboard items, it give developers the power to decide which thing to take from the user when pasting.

For example, developers can trigger resizing images when an image is detected in the clipboard, likewise with video content.

## Takeaways

The main idea behind the clipboard API is to enable developers to improve the user experience through asynchrounous clipboard processing.

The clipboard API provides read, write and permissions interface to be used. First the user needs to allow its usage and then developers can fine tune the clipboard handling between raw text and other media types such as images.

With this powerful API the testing aspect of it was left behind without any support to help developers to test their applications when writing test first.

As jest is one of the most popular test runners in the javascript ecosystem, jest-clipboard provides an API for developers to focus on the application behavior instead of mocking the clipboard API.

jest-clipboard follows the conversions used in the clipboard API and adds an readable API that shows intent. For example, writeText will write text to the clipboard and readText will read text from clipboard. It also provides utilities to setup and clean the test state to avoid behaviors that make testing hard.