Test service worker termination with Puppeteer

This guide explains how to build more robust extensions by testing service worker termination using Puppeteer. Being prepared to handle termination at any time is important because this can happen without warning resulting in any non-persistent state in the service worker being lost. Consequently, extensions must save important state and be able to handle requests as soon as they are started up again when there is an event to handle.

Before you start

Clone or download the chrome-extensions-samples repository. We'll use the test extension in /functional-samples/tutorial.terminate-sw/test-extension which sends a message to the service worker each time a button is clicked and adds text to the page if a response is received.

You'll also need to install Node.JS which is the runtime that Puppeteer is built on.

Step 1: Start your Node.js project

Create the following files in a new directory. Together they create a new Node.js project and provide the basic structure of a Puppeteer test suite using Jest as a test runner. See Test Chrome Extensions with Puppeteer to learn about this setup in more detail.

package.json:

{
  "name": "puppeteer-demo",
  "version": "1.0",
  "dependencies": {
    "jest": "^29.7.0",
    "puppeteer": "^22.1.0"
  },
  "scripts": {
    "start": "jest ."
  },
  "devDependencies": {
    "@jest/globals": "^29.7.0"
  }
}

index.test.js:

const puppeteer = require('puppeteer');

const SAMPLES_REPO_PATH = 'PATH_TO_SAMPLES_REPOSITORY';
const EXTENSION_PATH = `${SAMPLES_REPO_PATH}/functional-samples/tutorial.terminate-sw/test-extension`;
const EXTENSION_ID = 'gjgkofgpcmpfpggbgjgdfaaifcmoklbl';

let browser;

beforeEach(async () => {
  browser = await puppeteer.launch({
    // Set to 'new' to hide Chrome if running as part of an automated build.
    headless: false,
    args: [
      `--disable-extensions-except=${EXTENSION_PATH}`,
      `--load-extension=${EXTENSION_PATH}`
    ]
  });
});

afterEach(async () => {
  await browser.close();
  browser = undefined;
});

Notice that our test loads the test-extension from the samples repository. The handler for chrome.runtime.onMessage relies on state set in the handler for the chrome.runtime.onInstalled event. As a result, the contents of data will be lost when the service worker is terminated and responding to any future messages will fail. We will fix this after writing our test.

service-worker-broken.js:

let data;

chrome.runtime.onInstalled.addListener(() => {
  data = { version: chrome.runtime.getManifest().version };
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  sendResponse(data.version);
});

Step 2: Install dependencies

Run npm install to install the required dependencies.

Step 3: Write a basic test

Add the following test to the bottom of index.test.js. This opens the test page from our test extension, clicks the button element and waits for a response from the service worker.

test('can message service worker', async () => {
  const page = await browser.newPage();
  await page.goto(`chrome-extension://${EXTENSION_ID}/page.html`);

  // Message without terminating service worker
  await page.click('button');
  await page.waitForSelector('#response-0');
});

You can run your test with npm start and should see that it completes successfully.

Step 4: Terminate the service worker

Add the following helper function which terminates your service worker:

/**
 * Stops the service worker associated with a given extension ID. This is done
 * by creating a new Chrome DevTools Protocol session, finding the target ID
 * associated with the worker and running the Target.closeTarget command.
 *
 * @param {Page} browser Browser instance
 * @param {string} extensionId Extension ID of worker to terminate
 */
async function stopServiceWorker(browser, extensionId) {
  const host = `chrome-extension://${extensionId}`;

  const target = await browser.waitForTarget((t) => {
    return t.type() === 'service_worker' && t.url().startsWith(host);
  });

  const worker = await target.worker();
  await worker.close();
}

Finally, update your test with the following code. Now terminate the service worker and click the button again to check that you received a response.

test('can message service worker when terminated', async () => {
  const page = await browser.newPage();
  await page.goto(`chrome-extension://${EXTENSION_ID}/page.html`);

  // Message without terminating service worker
  await page.click('button');
  await page.waitForSelector('#response-0');

  // Terminate service worker
  await stopServiceWorker(page, EXTENSION_ID);

  // Try to send another message
  await page.click('button');
  await page.waitForSelector('#response-1');
});

Step 5: Run your test

Run npm start. Your test should fail which indicates that the service worker did not respond after it was terminated.

Step 6: Fix the service worker

Next, fix the service worker by removing its reliance on temporary state. Update the test-extension to use the following code, which is stored in service-worker-fixed.js in the repository.

service-worker-fixed.js:

chrome.runtime.onInstalled.addListener(() => {
  chrome.storage.local.set({ version: chrome.runtime.getManifest().version });
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  chrome.storage.local.get('version').then((data) => {
    sendResponse(data.version);
  });
  return true;
});

Here, we save the version to chrome.storage.local instead of a global variable to persist the state between service worker lifetimes. Since storage can only be accessed asynchronously, we also return true from the onMessage listener to make sure the sendResponse callback stays alive.

Step 7: Run your test again

Run the test again with npm start. It should now pass.

Next steps

You can now apply the same approach to your own extension. Consider the following:

  • Build your test suite to support running with or without unexpected service worker termination. You can then run both modes individually to make it clearer what caused a failure.
  • Write code to terminate the service worker at random points within a test. This can be a good way to discover issues that may be hard to predict.
  • Learn from test failures and try to code defensively in the future. For example, add a linting rule to discourage the use of global variables and try to move data into more persistent state.