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.