Skip to content

How to test a Manifest V3 Chrome extension with Puppeteer

6 min readModerok team

Load an unpacked MV3 extension in Puppeteer, get the extension ID, run code inside the service worker, read its console, and force it to terminate.

To test a Manifest V3 Chrome extension with Puppeteer, launch Chrome with enableExtensions pointing at your unpacked build directory and pipe: true, then wait for the extension's service worker target and evaluate code inside it:

import puppeteer from "puppeteer";

const browser = await puppeteer.launch({
  headless: false,
  pipe: true,
  enableExtensions: ["/abs/path/to/dist"],
});

const workerTarget = await browser.waitForTarget(
  (target) =>
    target.type() === "service_worker" && target.url().endsWith("background.js"),
);
const worker = await workerTarget.worker();
if (!worker) throw new Error("Could not attach to the extension service worker");

const extensionId = new URL(workerTarget.url()).hostname;

That is the whole handshake. The rest is what makes it survive CI.

Why your extension does not load by default

The most common failure is that browser.targets() never contains a service_worker, and people conclude Puppeteer cannot see MV3 extensions. It can; it just turns extensions off unless you ask. ChromeLauncher pushes exactly one of these two flags onto every launch:

chromeArguments.push(enableExtensions
    ? '--enable-unsafe-extension-debugging'
    : '--disable-extensions');

So a plain puppeteer.launch() starts Chrome with --disable-extensions. Puppeteer documents the option as avoiding "passing default arguments to the browser that would prevent extensions from being enabled." Any recipe built around --disable-extensions-except and --load-extension predates enableExtensions, which is the supported path now.

Two ways to load the extension

The array form used above installs each path for you at launch. The alternative is true plus a runtime install, which hands you the extension ID directly:

const browser = await puppeteer.launch({pipe: true, enableExtensions: true});
const extensionId = await browser.installExtension(pathToExtension);

Both go through the CDP Extensions.loadUnpacked command, not a command line flag, which is why pipe: true matters. Pass a list of paths over the default WebSocket transport and Puppeteer throws:

To use enableExtensions with a list of paths in Chrome, you must be connected with --remote-debugging-pipe (pipe: true).

Point the path at a directory containing a real manifest.json. For a plain extension that is your source tree; for a framework it is the build output, so run the build first. WXT writes to .output/chrome-mv3 by default and Plasmo's documented production output is build/chrome-mv3-prod.

Getting the extension ID

Chromium derives an unpacked extension's ID from the key field in its manifest if present, otherwise from the absolute path it was loaded from. Without a key, the ID changes whenever the path does, so a harness that builds into a fresh temp directory gets a new ID every run and any hardcoded value is a flake. Resolve it at runtime: take the return value of browser.installExtension(), or read it off the service worker URL, which looks like chrome-extension://<id>/background.js:

const extensionId = new URL(workerTarget.url()).hostname;

Running assertions inside the service worker

workerTarget.worker() returns a WebWorker whose evaluate() runs in the real extension context, so chrome.* APIs are available. This is the highest value trick in the setup: instead of asserting on side effects, read the extension's persisted state.

const state = await worker.evaluate(() => {
  return new Promise<Record<string, unknown>>((resolve) => {
    chrome.storage.local.get("__moderok__", (result) => {
      resolve(result["__moderok__"] as Record<string, unknown>);
    });
  });
});

expect(state.userId).toMatch(/^[0-9a-f]{8}-/);

Annotate the promise. evaluate() returns Promise<Awaited<ReturnType<Func>>>, so a bare new Promise((resolve) => ...) infers unknown and every property access after it is a type error.

Note the null check in the first snippet too. worker() is typed Promise<WebWorker | null> and returns null for any target that is not a service worker or shared worker, so under strict you have to narrow it. Throw rather than reaching for !: a null here means the extension never started. Match on something specific, too. target.url().endsWith("background.js") beats matching every service_worker, because a page you open during the test can register its own.

Reading the service worker console

Service worker logs do not show up in page.on("console"), since the worker is not a page. WebWorker is an event emitter with a console event of its own:

const logs: string[] = [];
worker.on("console", (message) => logs.push(message.text()));

That turns debug logging you already ship into assertions. If your extension prints a line when it finishes bootstrapping, asserting on that line beats anything checkable from outside.

Do not expect uncaught exceptions here. WebWorker also declares an error event, but Puppeteer builds the worker for a service worker target with a no-op exception handler, so nothing is emitted on it. Assert on persisted state and on the requests the worker makes, not on the absence of errors it will never report.

Testing the popup

A popup is a normal page at a chrome-extension:// URL, so once you have the ID you can just navigate to it:

const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`, {
  waitUntil: "domcontentloaded",
});
await page.click("#save");

That covers the DOM and the wiring, but not the real popup lifecycle, where the popup is destroyed the moment it loses focus. For the genuine article, Puppeteer's guide drives the toolbar button with chrome.action.openPopup() or page.triggerExtensionAction(extension), then waits for a target whose URL ends with popup.html.

Forcing the service worker to die

This is the test almost nobody writes and almost everybody needs. MV3 tears the service worker down after roughly 30 seconds of inactivity, and that is where most extension bugs live: in-memory state vanishes, listeners registered after an await are gone, message ports snap shut. Puppeteer triggers it on demand: for service worker targets WebWorker.close() is a Target.closeTarget plus a detach.

const workerUrl = workerTarget.url();
await worker.close();

// Wake it with something the extension actually listens for. A message from an
// extension page hits chrome.runtime.onMessage and restarts the worker.
const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`, {
  waitUntil: "domcontentloaded",
});
await page.evaluate(async () => {
  // The promise form rejects if no listener responds. The wake still happens.
  await chrome.runtime.sendMessage({type: "ping"}).catch(() => {});
});

const revivedTarget = await browser.waitForTarget(
  (t) => t !== workerTarget && t.type() === "service_worker" && t.url() === workerUrl,
  {timeout: 10_000},
);
const revivedWorker = await revivedTarget.worker();

Two details there are load bearing. First, swallow the sendMessage rejection. page.evaluate awaits the promise it returns, and the promise form rejects with "Could not establish connection. Receiving end does not exist." when no listener is registered, which is exactly the state you just engineered. It also rejects if a listener runs but never responds, which is the message port closed before a response was received. Second, pass an explicit timeout, since waitForTarget defaults to 30 seconds. The t !== workerTarget guard is cheap insurance: waitForTarget merges the current target list with the targetcreated and targetchanged streams, and you want the new worker.

Then assert that whatever was supposed to be durable still is. Anything in a module-level variable will not be. That is the failure mode behind a service worker that keeps stopping every 30 seconds, and a kill-and-revive test is the cheapest way to catch it before your users do.

Headless, CI, and flakiness

Two practical notes:

  • headless: 'shell' selects chrome-headless-shell, the stripped down headless binary, not the full browser. Do not use it for extension tests. Modern headless Chrome shares its code with headful Chrome, so headless: true is worth trying, but headless: false is what our own suite runs. On a CI runner that means providing a display, or the launch fails with "Missing X server to start the headful browser."
  • Never assert on a fixed sleep. Extension startup, storage writes, and network flushes are all asynchronous, and a timeout that passes on your laptop will fail on a loaded CI runner. Write a poll helper that retries until a predicate holds or a generous deadline passes.

One last thing worth wiring up early: assert against the data your extension actually sends, not just what it logs. Our own suite builds a throwaway MV3 extension into a temp directory, loads it with a more defensive version of the snippet at the top of this post, then queries the analytics database to confirm a row arrived for the event it tracked. Real extension, real Chrome, real request, real row. If you want the receiving end of that handled for you, Moderok is analytics built for extensions: zero dependencies, no host_permissions, and it drops into the test extension you are already launching.