PRACTICAL GUIDE / Playwright Chrome extension popup options testing

Test Chrome extension popups and options pages without toolbar automation

Learn how to load a Manifest V3 extension, verify shared popup and options state, and separate packaging defects from Chromium failures in CI.

By The Testing AcademyUpdated August 4, 202622 min read
All field guides
In this guide6 sections
  1. Why an ordinary browser test misses the extension
  2. Build a fixture that proves the package loaded
  3. Exercise the popup and options page as one workflow
  4. Separate extension defects from harness failures
  5. Roll the suite into CI without leaking profiles
  6. Know what this approach cannot prove

What you will learn

  • Why an ordinary browser test misses the extension
  • Build a fixture that proves the package loaded
  • Exercise the popup and options page as one workflow
  • Separate extension defects from harness failures

The popup saves a setting on your laptop, yet the options page reads the old value in CI. Both screens look like ordinary HTML, but they run inside an extension context that a default Playwright page never loads. If the test starts a normal browser context, a green check says nothing about the packaged extension.

That false positive is common when teams test a React popup on a development server and call the job an extension test. The component may render perfectly while the manifest points at a different file, the extension fails to load, or its storage call never runs. A useful suite has to prove the package is present before it asks whether the UI works.

Why an ordinary browser test misses the extension

Chrome extension pages use the chrome-extension:// scheme and receive privileges from an installed manifest. Opening popup.html through http://localhost changes that execution environment. The markup may be identical, but the origin, content security policy, extension APIs, service worker, and storage namespace are not. A component test is valuable for rendering logic; it is not evidence that the browser loaded the extension.

Playwright documents a narrower platform boundary than many teams expect. Extensions work only in Chromium and require a persistent browser context. The recommended setup uses the bundled Chromium channel, because current branded Chrome and Edge builds no longer accept the command-line flags Playwright needs for side-loading. This is not a cross-browser test disguised as one. It is a Chromium integration test.

The word "persistent" causes another mistake. It describes the type of browser context, not a requirement to share one user profile forever. chromium.launchPersistentContext() accepts a user data directory and returns the browser's only context. Passing an empty string asks Playwright to create a temporary directory. Closing that context also closes the browser. Those details let a test use the required persistent-context API without allowing yesterday's chrome.storage values to contaminate today's run.

Manifest V3 adds a background service worker. Its URL gives the test the generated extension ID, which is the host portion of the worker's chrome-extension:// URL. The official fixture pattern first checks context.serviceWorkers() and waits for the serviceworker event only when the list is empty. That order matters because the worker can start before the listener is registered.

Idle suspension is not the same as extension loss. Chromium can suspend an MV3 worker after inactivity. Playwright keeps the same Worker object when the browser restarts it on demand, and it does not emit a fresh serviceworker event for that restart. A helper that discards its handle and waits for another event can therefore time out even though the extension is healthy. An evaluation already in flight at the moment of suspension can fail, so a worker-operation failure needs its own retry policy at the operation boundary. It should not trigger extension reinstallation by default.

Popup and options pages have different hosts and lifetimes in the real product. A toolbar popup document is created when the user opens it and unloaded when the popup closes. An options_page document opens in a tab. For options_ui, open_in_tab defaults to false, so Chrome embeds the document inside the extension manager unless the manifest explicitly sets the option to true. The direct-navigation workflow below intentionally covers only tab-hosted options documents, meaning options_page or options_ui with open_in_tab: true. It does not claim coverage of embedded hosting inside chrome://extensions.

The manifest is the source of truth for page paths. Manifest V3 uses action.default_popup for a toolbar popup. An options document can be declared with options_ui.page or the older options_page key. Hard-coding /popup.html in every test creates a second configuration that silently drifts from the package. A rename can break the toolbar while the automated test continues opening an obsolete file that still happens to exist.

Build a fixture that proves the package loaded

Start with one fixture that owns launch, discovery, and shutdown. The example assumes a built, unpacked extension at extension/dist. Pointing the flags at a source directory that still needs bundling tests a different artifact from the one users install. Run the extension build before this fixture in your normal workflow, then treat the output directory as immutable during the test.

TypeScript
// tests/extension/fixtures.ts
import path from 'node:path';
import {
  chromium,
  test as base,
  type BrowserContext,
} from '@playwright/test';

type ExtensionFixtures = {
  extensionContext: BrowserContext;
  extensionId: string;
  extensionRoot: string;
};

export const test = base.extend<ExtensionFixtures>({
  extensionRoot: async ({}, use) => {
    await use(path.resolve(__dirname, '../../extension/dist'));
  },

  extensionContext: async ({ extensionRoot }, use) => {
    const context = await chromium.launchPersistentContext('', {
      channel: 'chromium',
      args: [
        `--disable-extensions-except=${extensionRoot}`,
        `--load-extension=${extensionRoot}`,
      ],
    });

    try {
      await use(context);
    } finally {
      await context.close();
    }
  },

  extensionId: async ({ extensionContext }, use) => {
    let [worker] = extensionContext.serviceWorkers();
    if (!worker) {
      worker = await extensionContext.waitForEvent('serviceworker');
    }

    const url = new URL(worker.url());
    if (url.protocol !== 'chrome-extension:' || !url.hostname) {
      throw new Error(`Unexpected extension worker URL: ${worker.url()}`);
    }

    await use(url.hostname);
  },
});

export const expect = test.expect;

This fixture has an oracle that can fail for a product-relevant reason. If Chromium does not load the package, there is no service worker to discover and the fixture fails. If discovery returns a non-extension URL, the explicit protocol check rejects it. Merely launching a browser is not the pass condition.

Using a separate fixture name, extensionContext, also prevents accidental use of Playwright's ordinary page fixture. Every extension page in the test must come from extensionContext.newPage(). You can instead override the built-in context fixture, as the official guide demonstrates, but a distinct name makes migrations safer: an unchanged test that still requests { page } cannot quietly run in the wrong context.

Read the manifest once and fail before navigation if either UI contract is missing. This helper recognizes the two options-page forms and gives options_ui precedence, matching the manifest relationship documented by MDN. It also rejects options_ui unless open_in_tab is explicitly true. The default is false, so treating an omitted value as tab-hosted would overstate what the direct-navigation tests cover.

TypeScript
// tests/extension/manifest.ts
import { readFile } from 'node:fs/promises';
import path from 'node:path';

type ExtensionManifest = {
  action?: { default_popup?: string };
  options_ui?: { page?: string; open_in_tab?: boolean };
  options_page?: string;
};

export async function readExtensionPages(extensionRoot: string) {
  const manifestPath = path.join(extensionRoot, 'manifest.json');
  const manifest = JSON.parse(
    await readFile(manifestPath, 'utf8'),
  ) as ExtensionManifest;

  const popup = manifest.action?.default_popup?.trim();
  const options = manifest.options_ui?.page?.trim()
    ?? manifest.options_page?.trim();

  if (!popup) {
    throw new Error('manifest.json does not declare action.default_popup');
  }
  if (!options) {
    throw new Error(
      'manifest.json does not declare options_ui.page or options_page',
    );
  }
  if (manifest.options_ui && manifest.options_ui.open_in_tab !== true) {
    throw new Error(
      'Direct options-page tests require options_ui.open_in_tab: true',
    );
  }

  return { popup, options };
}

Keep this parser deliberately small. A schema validator can do more, but a permissive type plus focused runtime checks makes the test's dependency visible. The code does not claim that every possible WebExtension manifest is valid. It extracts the two paths this suite needs, refuses to invent defaults, and fails rather than silently treating an embedded options_ui document as a tab. An extension that uses the default embedded mode needs a separate browser-level acceptance check inside Chrome's extension manager.

A repository with both source and built manifests should decide which one owns the result. For end-to-end coverage, inspect the built manifest beside the JavaScript Chromium loads. A unit test may validate the source manifest separately. Comparing one while launching the other creates a particularly frustrating failure: the diagnostic says the path is correct, while the running package follows a different path.

Exercise the popup and options page as one workflow

The first worked example protects a state transition users actually depend on. The popup enables a protection setting, and the options page must display the same saved value. This catches broken event wiring, storage writes that are not awaited, mismatched storage keys, and options-page hydration defects. A test that only checks whether the checkbox can be clicked catches none of those failures.

The accessible names below belong to the example product contract. Replace them with your extension's labels, but keep the sequence: act in one extension document, observe confirmation there, then create a separate document and verify persisted state. Opening a new page is intentional. Reusing one DOM would only prove component state.

TypeScript
// tests/extension/settings.spec.ts
import { test, expect } from './fixtures';
import { readExtensionPages } from './manifest';

test('a popup setting is visible in the options page', async ({
  extensionContext,
  extensionId,
  extensionRoot,
}) => {
  const pages = await readExtensionPages(extensionRoot);
  const popup = await extensionContext.newPage();
  await popup.goto(`chrome-extension://${extensionId}/${pages.popup}`);

  const protection = popup.getByRole('checkbox', {
    name: 'Block tracking requests',
  });
  await protection.check();
  await expect(protection).toBeChecked();
  await expect(popup.getByRole('status')).toHaveText('Protection is on');

  const options = await extensionContext.newPage();
  await options.goto(`chrome-extension://${extensionId}/${pages.options}`);
  await expect(
    options.getByRole('checkbox', { name: 'Block tracking requests' }),
  ).toBeChecked();
});

test('an invalid allow-list entry is not persisted', async ({
  extensionContext,
  extensionId,
  extensionRoot,
}) => {
  const { options: optionsPath } = await readExtensionPages(extensionRoot);
  const options = await extensionContext.newPage();
  await options.goto(`chrome-extension://${extensionId}/${optionsPath}`);

  await options.getByLabel('Allowed site').fill('not a host name');
  await options.getByRole('button', { name: 'Add site' }).click();
  await expect(options.getByRole('alert')).toHaveText(
    'Enter a valid host name',
  );

  await options.reload();
  await expect(options.getByRole('list', { name: 'Allowed sites' }))
    .not.toContainText('not a host name');
});

The second case is not a cosmetic variation of the first. It verifies a rejection path and durable absence. The alert alone is a weak oracle because an implementation could display the error and still write the invalid value. Reloading and checking the rendered list makes a storage defect fail the test. Conversely, checking only storage internals would bypass the options-page read path the user sees.

A third useful case starts from the options page, saves several fields, closes that page, and opens the popup to verify the compact summary. This direction often exposes a different bug because options forms batch writes on a Save button while popups tend to write immediately. Do not combine both directions into one enormous scenario. Separate cases produce a clearer first failure and let each test begin with a temporary profile.

One popup category needs a different test design: actions against the currently active web tab. A real toolbar popup is not itself a normal tab, so the page behind it remains the user's active content tab. A popup opened by direct navigation is a tab. Code that asks the extension platform for the active tab may therefore observe the test document instead of the shopping page, issue tracker, or dashboard the user was viewing. The popup can render correctly while the central action has the wrong target.

Do not hide that mismatch by stubbing the result inside the end-to-end case and then claiming toolbar coverage. Split the contract. Test the function that converts tab information into the intended message with ordinary unit cases, including prohibited schemes and missing tab IDs. Test service-worker or content-script handling through a supported extension integration path. Keep one browser acceptance check for the toolbar-to-active-tab handoff until the automation surface can reproduce it honestly. The directly navigated page remains the right tool for settings, validation, navigation links, storage hydration, and other behavior that does not depend on browser-chrome ownership.

An options-page migration offers another worked failure. Suppose version two renames blockedHosts to siteRules. Existing users still have the old key, while every temporary automation profile starts empty. A fresh-profile test passes and production upgrades lose settings. Add a seeded migration case, but make its purpose explicit: seed the previous released schema through a small migration fixture, load the new options page, and assert the old host appears in the new UI. Then reopen the page to prove the converted state is durable. Keep this case separate from clean-install tests because its setup intentionally bypasses the old UI. Its oracle is a visible migrated rule, not merely the presence of a newly named storage key.

Migration fixtures should be versioned with released schemas, not copied from whatever object the current code writes. If both setup and implementation import the same current constant, a breaking rename can update them together and leave the test green. A frozen old-state sample can fail when conversion is removed or points at the wrong field. The maintenance cost is real: every supported upgrade boundary needs a small, reviewed sample. Pay that cost only for extension state the product promises to preserve.

Be precise about what direct navigation proves. It proves that the declared extension document loads under the generated extension origin and that its user-visible workflow works in the installed package. It does not prove toolbar-icon placement, the browser's calculated popup dimensions, focus transfer from browser chrome, or automatic unloading when focus leaves the popup. If your code commits data only during unload, the stable page test may hide that dependency. Prefer explicit saves that can be observed before teardown, then retain a small manual or browser-shell check for lifecycle behavior.

Avoid reaching into the page to set chrome.storage as test setup unless storage itself is not under test. Doing so can be appropriate for a narrow rendering case, but it removes the write path from coverage. Label such a test as a seeded-state UI check. The end-to-end state case should enter data through the popup or options controls and observe it through the other surface.

Separate extension defects from harness failures

A timeout at the first locator can mean four different things: the extension never loaded, the manifest points elsewhere, navigation reached an error document, or the UI loaded and changed. Raising the locator timeout blurs those causes. Record the boundary facts before looking at the control.

The diagnostic below attaches the built manifest, all current page URLs, and all known service-worker URLs. Each value answers a specific question. The manifest says what was packaged. The worker URL says which extension ID Chromium assigned. The page URL says what document actually opened.

Resist the obvious-looking assertion here, which is to compare the loaded page's hostname against the extension ID. It cannot fail. The URL was built from extensionId one line earlier, so the check compares a value with itself, and every way the navigation could go wrong throws inside page.goto() before the assertion is reached. A path that is not in the package raises net::ERR_FILE_NOT_FOUND, and a hostname that is not a loaded extension raises net::ERR_BLOCKED_BY_CLIENT. Whenever the assertion runs at all, it has already been guaranteed by the line above it.

Assert a privileged capability instead. The question worth answering after navigation is not "is this URL the URL I just typed" but "is this document really running in the extension execution environment". Reading the extension API surface the popup depends on answers that, and it has a failing path that a packaging change can reach: drop storage from the manifest's permissions and the assertion reports undefined instead of function. It also fails for the false positive this article opened with, because the same HTML served from a development server has no chrome.storage at all.

TypeScript
// tests/extension/diagnostics.spec.ts
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test, expect } from './fixtures';
import { readExtensionPages } from './manifest';

type ExtensionGlobal = {
  chrome?: { storage?: { local?: { get?: unknown } } };
};

test('popup package smoke check', async ({
  extensionContext,
  extensionId,
  extensionRoot,
}, testInfo) => {
  const manifestPath = path.join(extensionRoot, 'manifest.json');
  await testInfo.attach('built-manifest.json', {
    body: await readFile(manifestPath),
    contentType: 'application/json',
  });

  const { popup } = await readExtensionPages(extensionRoot);
  const page = await extensionContext.newPage();
  await page.goto(`chrome-extension://${extensionId}/${popup}`);

  const runtime = {
    pages: extensionContext.pages().map(candidate => candidate.url()),
    serviceWorkers: extensionContext.serviceWorkers().map(worker => worker.url()),
  };
  await testInfo.attach('extension-runtime.json', {
    body: Buffer.from(JSON.stringify(runtime, null, 2)),
    contentType: 'application/json',
  });

  const privilegedStorage = await page.evaluate(
    () => typeof (globalThis as unknown as ExtensionGlobal)
      .chrome?.storage?.local?.get,
  );
  expect(privilegedStorage).toBe('function');

  await expect(page.getByRole('heading', { name: 'Tracker protection' }))
    .toBeVisible();
});

Name the capability your popup actually uses. If the package declares tabs or scripting rather than storage, probe that member instead. The point is to read something the manifest grants and an ordinary page cannot have, so a permission regression or a wrong execution environment produces a red test rather than a green one.

When discovery fails before a page exists, inspect the browser launch inputs first. Confirm that the directory exists in the CI workspace and contains the built manifest.json. A path based on the caller's current working directory may succeed locally and point nowhere in a job that starts from another directory. Resolve from the fixture module or a stable repository root, and attach the resolved path in the failure message without dumping unrelated environment data.

When the worker exists but page.goto() cannot load the requested extension document, compare the requested path with the attached built manifest and build output. This is usually manifest drift or a missing packaged asset, not a slow page. Be exact about where the evidence lives, because the intuitive place to look is empty. Chromium does not render an error page for a failed chrome-extension navigation the way it does for a broken web page. The navigation rejects, so page.goto() throws, the assertions after it never run, and page.url() is still about:blank with no document to inspect. Reaching for the final URL and the visible document at that moment returns nothing useful, and evaluating against the page raises a separate execution-context error that sends the investigation down the wrong path.

The actionable evidence is the message on the thrown error, which names the failure Chromium reported. A path missing from the package produces page.goto: net::ERR_FILE_NOT_FOUND at chrome-extension://<id>/<path>, which points at the build output or a renamed file. A hostname that is not a loaded extension produces page.goto: net::ERR_BLOCKED_BY_CLIENT at ..., which points at extension discovery rather than at the file. Those two strings separate a packaging defect from an identity defect in one line, so let the error propagate with its message intact, or catch it and attach the message alongside the manifest. A response status is not the right oracle for these navigations either, since there is no response to read. The trace's action list still shows where the navigation failed in the sequence, while its DOM snapshots show whether any later document rendered. Attachments preserve the facts that the trace does not infer, such as which manifest file the Node process read.

When headings render but buttons do nothing, collect page errors and relevant console messages before blaming storage. Extension pages apply the package's content security policy. A bundle that contains disallowed inline script or loads a chunk from an unavailable location may leave static HTML on screen with no event handlers. The visible heading proves only that the document parsed. In the trace, compare the click action with the DOM before and after it, then inspect console output for a policy or script-loading failure. Also inspect the built HTML's script references in the attached artifact. A missing hashed chunk is a packaging defect; a locator that never became actionable is a UI or selector problem; a successful click followed by no state change points farther into product logic.

Register diagnostic listeners before navigation when the failure happens during startup. A listener added after the popup loads cannot recover an earlier page error. Keep the captured messages scoped to the extension page and attach them to the failing test instead of streaming every browser message into shared CI logs. Console output can contain visited URLs or user-derived data in some extensions, so sanitize known sensitive fields and retain only what the team needs to classify the failure.

The options page can fail for a different packaging reason while the popup works. Modern builds often create separate entry chunks. The shared manifest and worker prove the extension loaded, but they do not prove every declared HTML file references an emitted bundle. Open both pages in the smoke layer, assert a stable landmark on each, and attach the final URLs. That small addition distinguishes whole-package launch failure from one broken entry point without duplicating the deeper settings scenarios.

When the page loads but shared state is stale, decide whether the write confirmation is honest. A status message rendered immediately on click may precede the asynchronous storage write. The cross-page assertion then fails intermittently. Fix the product so success is shown after the write resolves, or give the destination page an observable loading state and wait for that state to finish. Sleeping after the click turns a race into a slower race.

A stale value present at the beginning of an allegedly isolated test points to profile reuse. Logically unrelated tests should not depend on execution order. With an empty user data directory per test, that state should not exist unless the extension seeds it. If you deliberately move context creation to worker scope for speed, write an explicit reset fixture and prove the reset through the UI or extension-owned reset API. Deleting arbitrary profile files while Chromium is running is not a safe reset mechanism.

One near-miss deserves special attention. A missing second serviceworker event after an idle period looks like a dead extension, but current Playwright behavior retains the original Worker object across an MV3 restart. Check whether initial discovery succeeded and whether the failure happened during an evaluation. Do not add an unconditional wait for a replacement event. That wait creates a timeout by design.

Roll the suite into CI without leaking profiles

Put extension tests in their own project or command. They launch Chromium differently, cost more than ordinary page tests, and cannot provide Firefox or WebKit coverage. A separate lane makes that platform contract visible instead of silently skipping two thirds of a shared browser matrix.

The job must install Playwright's bundled Chromium and build the unpacked extension before running the tests. It should not select branded Chrome for this lane. The following workflow uses npm consistently: dependencies are installed before the Playwright browser command, and the build precedes tests that read extension/dist.

YAML
name: extension-integration

on:
  pull_request:

jobs:
  chromium-extension:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build:extension
      - run: npx playwright test tests/extension
        env:
          CI: "true"

Treat extension/dist as a build result, not a shared scratch directory. Parallel jobs can each build in their own checkout. Within one job, do not rebuild the directory while extension contexts are running. Chromium reads packaged resources during the session, so replacing files underneath it creates states users never receive.

Temporary profiles buy isolation at the cost of launch time. Every test in the fixture above starts a new Chromium process because a persistent context is the browser's only context. For a small critical suite, that cost is usually worth paying. For dozens of read-only rendering cases, split the suite: keep a few per-test end-to-end cases, and run stateless popup layout checks in a worker-scoped extension context. Any worker-scoped test that changes storage must either own a proven reset or run serially with documented ordering. Serial execution lowers concurrency and still does not protect against a prior test aborting before cleanup.

Retain traces on the first retry or on failure according to the repository's normal Playwright policy. A trace helps with page actions, DOM snapshots, console output, and network activity. It cannot tell you that CI built the wrong source revision unless you attach artifact identity yourself. A small attachment containing manifest version, extension path, and relevant build identifier is better than printing the entire environment.

Roll out in three steps. First, add the package smoke check and run it without blocking merges. It will expose path and browser-install assumptions. Second, move one storage workflow from the development-server suite into the extension context and compare failures for several days. Third, make the smoke check and the cross-page state case required, then migrate additional cases only when they depend on extension privileges. This sequence keeps infrastructure noise from discrediting the new lane.

Do not copy every popup component test into Chromium. Validation functions, conditional rendering, and pure state reducers remain cheaper at their existing layer. Reserve the installed-package lane for manifest routing, extension origin behavior, privileged API integration, service-worker messaging, and state shared between extension documents.

Know what this approach cannot prove

Do not use direct popup navigation as evidence that the toolbar button itself works. The test never clicks browser chrome. It does not verify that the icon is visible, that a per-tab popup override selects the expected document, or that the popup closes on blur. Those risks need a browser-level manual check, a focused extension acceptance procedure, or another supported automation surface that genuinely controls the toolbar.

Do not keep a developer's real Chrome profile to make authentication or extension settings convenient. Playwright warns against automating the default Chrome user profile, and Chromium does not allow multiple instances to use the same user data directory. Beyond reliability, a personal profile exposes browsing data and credentials to test code. Seed only the minimum extension-owned state in an isolated automation profile.

Do not claim cross-browser compatibility from this suite. Playwright's extension support is Chromium-only. Shared WebExtension code can still receive unit coverage, and Firefox behavior can be checked with tooling appropriate to that browser, but a Chromium extension result cannot stand in for it. Put the browser name in the project and report so nobody reads more coverage into the green status.

Do not wait for a service worker when testing a Manifest V2 package that uses a background page. The fixture above is intentionally for Manifest V3. A timeout would reflect a mismatched harness, not a broken popup. Confirm the manifest version before adopting the discovery mechanism.

Do not use an extension integration test to settle CSS details that a component test can diagnose faster. Starting a browser process and loading an unpacked package increases latency, consumes more CI memory, and produces more possible infrastructure failures. The additional cost is justified only when the extension boundary can change the result.

Finally, do not turn every internal implementation detail into an assertion. Worker script filenames, generated IDs, and intermediate storage keys can change without harming users. Assert that a worker for the loaded package exists, that navigation uses the discovered ID, and that user-visible state survives the real cross-page path. Those checks fail when the extension contract breaks, not merely when the team refactors it.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does my Chrome extension test pass without loading the extension?

A normal browser context can still open a copied HTML file or a development server, so UI assertions may pass while extension APIs are absent. Require a chrome-extension URL and a discovered extension service worker before treating the run as an extension test.

Can Playwright open an extension popup by URL?

Direct navigation to the popup's chrome-extension URL is the documented way to test its page content. That covers the extension document and its interactions, but it does not prove that clicking the browser toolbar icon opens the popup.

Should extension tests reuse one persistent Chrome profile?

Reusing one directory saves launch time but carries storage, permissions, and cached extension data between tests. Give each isolated test a temporary profile, or reset every owned state key when a worker-scoped profile is an intentional performance trade-off.

Why did no new serviceworker event appear after the MV3 worker restarted?

Playwright keeps the same Worker object across an idle suspension and restart, so another serviceworker event is not expected. Retain the original handle and diagnose an in-flight evaluation failure separately from extension discovery.

Does this replace a manual check of the real toolbar popup?

Keep a small browser-level acceptance check when popup placement, focus, dimensions, or close-on-blur behavior matters. Direct page tests are faster and more diagnostic, but they deliberately bypass Chrome's toolbar surface.