Testing a Popup and Options Page with Playwright
Test MV3 extension pages end to end — loading popup.html as a tab, simulating the active tab the popup expects, asserting storage side effects, and what the page-as-tab approach cannot cover.
Table of Contents
Playwright cannot click your toolbar icon — the toolbar is browser chrome, not page content — so it cannot open the popup the way a user does. What it can do is open chrome-extension://<id>/popup.html in a tab, which runs exactly the same document with exactly the same chrome.* access. That covers nearly everything worth testing, with two gaps you need to know about: the popup’s size constraints and its close-on-blur lifecycle. This guide is part of end-to-end testing automation.
What the tab approach covers
Step-by-step
1. Reuse the extension fixture
1// e2e/fixtures.js — exports test with `context` and `extensionId`
2import { test as base, expect, chromium } from "@playwright/test";
3import path from "node:path";
4
5export const test = base.extend({
6 context: async ({}, use) => {
7 const ext = path.resolve("dist/chrome");
8 const ctx = await chromium.launchPersistentContext("", {
9 headless: true,
10 args: [`--disable-extensions-except=${ext}`, `--load-extension=${ext}`],
11 });
12 await use(ctx);
13 await ctx.close();
14 },
15 extensionId: async ({ context }, use) => {
16 const sw = context.serviceWorkers()[0] ?? await context.waitForEvent("serviceworker");
17 await use(new URL(sw.url()).host);
18 },
19});
20export { expect };
Execution context: Node, under Playwright. The fixture is described in loading an unpacked extension in Playwright; every test below gets a fresh profile and the extension id.
2. Open the popup as a page at popup size
1test("popup shows the empty state on first run", async ({ context, extensionId }) => {
2 const page = await context.newPage();
3 await page.setViewportSize({ width: 360, height: 600 });
4 await page.goto(`chrome-extension://${extensionId}/popup.html`);
5
6 await expect(page.getByRole("heading", { name: "Nothing saved yet" })).toBeVisible();
7 await expect(page.getByRole("button", { name: "Save this page" })).toBeEnabled();
8});
Execution context: Node drives the browser; the popup document runs in the extension’s origin. Setting the viewport to your popup’s real width catches layout that only works when the tab is wide. Role-based locators double as a light accessibility check — if getByRole("button", { name }) cannot find the control, a screen reader probably cannot either.
3. Give the popup a real “active tab”
Popups usually start by querying the active tab. Opened as a tab itself, the popup is the active tab, which breaks that logic. Point it at a real page instead.
1test("save works against the page the user is on", async ({ context, extensionId }) => {
2 const article = await context.newPage();
3 await article.goto("https://example.com/");
4
5 const popup = await context.newPage();
6 await popup.goto(`chrome-extension://${extensionId}/popup.html?tab=${await tabIdOf(article)}`);
7 await popup.getByRole("button", { name: "Save this page" }).click();
8
9 await expect(popup.getByRole("status")).toHaveText(/Saved/);
10});
Execution context: Node and the popup document. The popup should accept an optional ?tab= override and fall back to tabs.query({ active: true }) otherwise — a tiny testing seam that also makes the popup more robust. tabIdOf is a helper that asks the worker for the tab id matching the page’s URL, using the approach in driving service worker state from a test.
4. Assert on storage, not only on the DOM
The popup’s job is usually to change something the rest of the extension depends on. Check that directly.
1const saved = await popup.evaluate(() => chrome.storage.local.get("items").then((r) => r.items ?? []));
2expect(saved).toHaveLength(1);
3expect(saved[0].url).toBe("https://example.com/");
Execution context: page.evaluate runs the function inside the popup document, where chrome.storage is available. This is the assertion that catches a UI that says “Saved” without saving anything.
5. Test the options page the same way
1test("changing the theme persists and applies", async ({ context, extensionId }) => {
2 const options = await context.newPage();
3 await options.goto(`chrome-extension://${extensionId}/options.html`);
4
5 await options.getByRole("radio", { name: "Dark" }).check();
6 await expect(options.getByRole("status")).toHaveText("Saved");
7
8 await options.reload();
9 await expect(options.getByRole("radio", { name: "Dark" })).toBeChecked();
10 expect(await options.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
11});
Execution context: the options page, which is already a tab-shaped surface — testing it this way loses nothing. Reloading proves the setting was persisted rather than only held in memory, and the form patterns being exercised are those in accessible form controls for extension settings.
6. Cover the popup lifecycle with a targeted test
The tab approach never closes on blur, so state that should be flushed on pagehide is not exercised. Simulate it.
1test("draft survives the popup closing", async ({ context, extensionId }) => {
2 let popup = await context.newPage();
3 await popup.goto(`chrome-extension://${extensionId}/popup.html`);
4 await popup.getByLabel("Note").fill("half-written");
5 await popup.close(); // fires pagehide
6
7 popup = await context.newPage();
8 await popup.goto(`chrome-extension://${extensionId}/popup.html`);
9 await expect(popup.getByLabel("Note")).toHaveValue("half-written");
10});
Execution context: Node and two successive popup documents. Closing the page fires pagehide, which is the event the real popup relies on — a close approximation of the real lifecycle described in why the popup closes and how to work with it.
Keeping extension-page tests maintainable
UI tests for extension pages rot for the same reasons any UI tests do, plus one extension-specific reason: every test launches a whole browser with the extension, so a slow suite is felt quickly. A few conventions keep it healthy.
Locate by role and label, not by CSS. Role-based locators survive restyling and test the accessibility tree at the same time. A test that needs .btn-primary > span:nth-child(2) is testing the stylesheet.
Seed state through storage, not through the UI. A test about the list view should not first click “save” twenty times. Write the twenty items into storage from the worker and start the test from there.
1await sw.evaluate((items) => chrome.storage.local.set({ items }), fixtureItems);
Execution context: the service worker, via Playwright’s Worker.evaluate. Seeding is faster and isolates the test to the behaviour it is named after.
One browser per file, not per test, where isolation allows. Launching a persistent context costs a second or two. Tests that do not mutate shared state can share a context within a file, with storage cleared between tests.
Cross-browser variation
- Chrome / Edge:
chrome-extension://<id>/popup.htmlloads in a tab with full API access; the extension id is read from the service worker URL. - Firefox: extension pages use
moz-extension://<uuid>/, where the uuid is per-install. Playwright’s Firefox support for loading WebExtensions is limited;web-ext runwith Selenium or a dedicated harness is more common, as in testing extensions in Firefox with web-ext. - Safari: extension pages cannot be automated through Playwright. Test the shared UI code in a Chromium run and cover Safari-specific behaviour manually.
- All three: the popup-as-tab never enforces the popup size cap or the close-on-blur lifecycle. Cover those with the targeted tests above and a manual check before release.
Verification
- Run the suite and confirm every test opens extension pages rather than timing out on the service worker gate.
- Break the save handler deliberately and confirm the storage assertion fails even though the “Saved” text still appears.
- Check the popup at its real width:
1await page.setViewportSize({ width: 360, height: 600 });
2expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360);
Execution context: the popup document in the test. A wider scrollWidth means content overflows the real popup — the layout problem covered in fixing popup size and overflow issues.
- Confirm the draft test fails if the
pagehideflush is removed.
FAQ
Can Playwright open the real popup?
Not through a supported API — the toolbar is outside the page. Opening popup.html in a tab is the standard, and for everything except size and lifecycle it is equivalent.
How do I test keyboard shortcuts that open the popup?
You cannot trigger browser-level commands from Playwright. Test the command’s handler directly in the worker, and cover the shortcut itself manually.
Should popup tests run against every browser?
Run them against Chromium in CI on every commit. Firefox and Safari differences are better covered by targeted tests on the specific behaviours that differ.
Related
- Loading an unpacked extension in Playwright — the fixture these tests use.
- Driving service worker state from a test — seeding state and finding tab ids.
- Stabilising flaky extension tests — keeping these reliable in CI.
- End-to-end testing automation — the parent guide.