PRACTICAL GUIDE / Playwright Manifest V3 extension service worker testing
Test a Manifest V3 extension without chasing dead workers
Launch a Manifest V3 extension in Playwright, find its service worker reliably, survive idle restarts, and keep parallel CI browser profiles isolated.
In this guide7 sections
- Launch the browser surface the extension actually needs
- Prove worker identity before testing extension behavior
- Handle MV3 suspension without waiting for a second worker
- Diagnose startup, discovery, and execution as separate failures
- The extension worker exists in a different context
- Keep persistent profiles isolated in parallel CI
- Migrate an existing extension suite in dependency order
- Know when a real extension browser is the wrong layer
What you will learn
- Launch the browser surface the extension actually needs
- Prove worker identity before testing extension behavior
- Handle MV3 suspension without waiting for a second worker
- Diagnose startup, discovery, and execution as separate failures
An extension popup works on a developer laptop, but CI never finds its background worker. The job launched a normal browser context in branded Chrome, so the extension was never loaded. A Manifest V3 test needs a persistent context in Playwright's bundled Chromium before any popup assertion can mean anything.
Once the worker appears, the test must keep the right Playwright Worker handle because an idle restart does not emit another service-worker event.
Launch the browser surface the extension actually needs
Playwright's extension guide sets a narrow support boundary. Extensions work only in Chromium launched with a persistent context. Google Chrome and Microsoft Edge removed the command-line flags used for side-loading, so the current guidance is to use the Chromium bundled with Playwright. The chromium channel also supports extension execution in headless mode.
That leads to four non-negotiable pieces of setup:
- Call
chromium.launchPersistentContext(), notchromium.launch()followed bybrowser.newContext(). - Give the persistent context a user-data directory that is unique to the test process.
- Pass
--disable-extensions-except=<path>and--load-extension=<path>for the unpacked extension. - Use
channel: 'chromium'for the documented headless path, or run headed when visually debugging.
Custom browser arguments are powerful and can break Playwright behavior, so keep the list short. Do not paste a large collection of launch flags from an unrelated Selenium setup. Each extra flag becomes another variable when the worker is absent.
A test-scoped fixture gives each test its own profile and closes the persistent context after use. The extension path comes from a stable repository location or an environment override, while the profile lives under the test's output directory.
// fixtures/extension.ts
import { existsSync } from 'node:fs';
import path from 'node:path';
import {
chromium,
test as base,
type BrowserContext,
type Worker,
} from '@playwright/test';
type ExtensionFixtures = {
extensionContext: BrowserContext;
extensionWorker: Worker;
extensionId: string;
};
const extensionPath = path.resolve(
process.env.EXTENSION_PATH ?? path.join(process.cwd(), 'extension'),
);
export const test = base.extend<ExtensionFixtures>({
extensionContext: async ({}, use, testInfo) => {
if (!existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error(`Extension manifest not found at ${extensionPath}`);
}
const context = await chromium.launchPersistentContext(
testInfo.outputPath('chromium-profile'),
{
channel: 'chromium',
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
},
);
try {
await use(context);
} finally {
await context.close({ reason: 'extension fixture teardown' });
}
},
extensionWorker: async ({ extensionContext }, use) => {
const isExtensionWorker = (worker: Worker) =>
worker.url().startsWith('chrome-extension://');
let worker = extensionContext.serviceWorkers().find(isExtensionWorker);
worker ??= await extensionContext.waitForEvent('serviceworker', {
predicate: isExtensionWorker,
});
await use(worker);
},
extensionId: async ({ extensionWorker }, use) => {
const id = new URL(extensionWorker.url()).host;
if (!id) {
throw new Error(`Could not read extension ID from ${extensionWorker.url()}`);
}
await use(id);
},
});
export { expect } from '@playwright/test';The fixture checks existing service workers before waiting. Startup can create the extension worker before the fixture reaches waitForEvent; always waiting would turn a successful fast startup into a timeout. The predicate also rejects service workers registered by a web application opened in the same context.
Using serviceWorkers()[0] is shorter and brittle. A PWA, a second extension, or a changed startup sequence can reorder the array. Filter on the chrome-extension: scheme and, when loading more than one extension, filter on a known worker script path or verify the manifest identity through the worker.
For a test harness that may load several unpacked extensions, read the expected background script from the built manifest and include it in the predicate. That prevents a valid worker from the wrong extension satisfying setup.
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { BrowserContext, Worker } from '@playwright/test';
type ManifestV3 = {
manifest_version: number;
background?: { service_worker?: string };
};
export async function findManifestWorker(
context: BrowserContext,
unpackedExtensionPath: string,
): Promise<Worker> {
const manifest = JSON.parse(
await readFile(path.join(unpackedExtensionPath, 'manifest.json'), 'utf8'),
) as ManifestV3;
if (manifest.manifest_version !== 3 || !manifest.background?.service_worker) {
throw new Error('Built extension does not declare a Manifest V3 service worker');
}
const expectedPath = `/${manifest.background.service_worker.replace(/^\//, '')}`;
const matches = (worker: Worker) => {
const url = new URL(worker.url());
return url.protocol === 'chrome-extension:' && url.pathname === expectedPath;
};
return context.serviceWorkers().find(matches)
?? context.waitForEvent('serviceworker', { predicate: matches });
}This validation moves a bad build forward to fixture setup. Its cost is coupling the harness to the built manifest path, which is worthwhile when CI can accidentally point at a stale or partial bundle. If the extension intentionally generates a different worker filename per build, compare against the generated manifest rather than hard-coding a source filename.
The fixture deliberately pays for a browser launch per test. That is slower than a worker-scoped context, but it isolates extension storage and browser profile state. Move it to worker scope only after identifying and resetting every state surface the extension persists, and only if the runtime savings justify the added coupling.
Prove worker identity before testing extension behavior
The first useful test is not a full workflow. It proves that Playwright loaded an extension worker, that the URL yields an ID, and that the extension runtime reports the same ID.
// tests/extension-smoke.spec.ts
import { test, expect } from '../fixtures/extension';
test('loads the expected Manifest V3 worker', async ({
extensionWorker,
extensionId,
}) => {
expect(extensionWorker.url()).toMatch(
new RegExp(`^chrome-extension://${extensionId}/`),
);
const runtimeId = await extensionWorker.evaluate(() => {
const extensionApi = (
globalThis as typeof globalThis & {
chrome: { runtime: { id: string } };
}
).chrome;
return extensionApi.runtime.id;
});
expect(runtimeId).toBe(extensionId);
});This test catches launch and discovery errors without depending on a popup, content script, or external site. It also gives CI a small target to run when the full extension suite suddenly cannot start.
The next test should use a user-visible surface. If the sample extension declares popup.html as its action popup, navigate directly to that extension URL. Direct navigation is more stable than automating Chromium's toolbar UI, and it still runs the real extension page from the loaded package.
// tests/popup.spec.ts
import { test, expect } from '../fixtures/extension';
test('persists the enabled setting in the popup', async ({
extensionContext,
extensionId,
}) => {
const popup = await extensionContext.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
const enabled = popup.getByRole('checkbox', { name: 'Enable protection' });
await enabled.check();
await expect(popup.getByRole('status')).toHaveText('Protection enabled');
await popup.reload();
await expect(enabled).toBeChecked();
});Use the popup path and accessible names from the extension under test. The structure above is runnable for an extension with that declared page and UI contract; it is not a claim that every extension has popup.html or the same controls.
Direct popup navigation has a trade-off. It verifies the extension page and its communication with background code, but it does not prove the browser toolbar entry is visible or that a user can click the action icon. Browser chrome automation is a separate, more fragile system-level concern. Keep most behavior coverage at extension-page and content-script boundaries, then reserve a small manual or platform test for installation chrome if the release requires it.
A third useful example checks the content script on a controlled page. Serve a deterministic local fixture page and assert the DOM effect that a user would see. Do not use a public website whose markup and policies can change independently of the extension.
import { test, expect } from '../fixtures/extension';
test('marks a risky link on the fixture site', async ({ extensionContext }) => {
const page = await extensionContext.newPage();
await page.goto('http://127.0.0.1:4173/extension-fixtures/risky-link.html');
const warning = page.getByRole('status', { name: 'Suspicious link warning' });
await expect(warning).toBeVisible();
await expect(page.getByRole('link', { name: 'Continue to example' }))
.toHaveAttribute('data-extension-reviewed', 'true');
});This example assumes the repository starts that fixture server through Playwright's webServer configuration and that the extension's manifest matches the local origin. If the warning never appears, inspect worker presence, content-script match patterns, and page console errors separately. A loaded service worker proves installation, not content-script eligibility for every URL.
Handle MV3 suspension without waiting for a second worker
Manifest V3 replaced a permanently running background page with an extension service worker that can suspend while idle. Playwright's current extension documentation describes suspension after roughly 30 seconds of inactivity. That duration is platform behavior, not a timer your test should use as a sleep target.
Playwright keeps the same Worker object alive across the idle restart. No new context serviceworker event is emitted for that restart. A new worker.evaluate() issued during the restart window waits until the new execution context is ready and then continues.
One edge remains. An evaluation already in flight at the exact moment of suspension can throw an error containing Service worker restarted. Treat that as an interrupted operation, not as evidence that the saved worker handle is permanently invalid.
For an idempotent read, one narrow retry can make the diagnostic resilient:
import type { Worker } from '@playwright/test';
export async function readExtensionRuntimeId(worker: Worker): Promise<string> {
const read = () => worker.evaluate(() => {
const extensionApi = (
globalThis as typeof globalThis & {
chrome: { runtime: { id: string } };
}
).chrome;
return extensionApi.runtime.id;
});
try {
return await read();
} catch (error) {
const wasRestart =
error instanceof Error && error.message.includes('Service worker restarted');
if (!wasRestart) {
throw error;
}
return read();
}
}The retry is safe because reading runtime.id has no side effect. Do not wrap every service-worker evaluation in the same helper. A command that writes storage, submits telemetry, opens a tab, or changes a rule may have completed before the client saw an interruption. Replaying it could duplicate the change.
For mutations, prefer a user-facing trigger with an idempotency key or a way to query resulting state. Trigger once, then read state after the worker is ready. If the trigger itself reports an ambiguous interruption, fail with evidence rather than guessing whether it ran.
Do not write this pattern:
// Wrong after an MV3 idle restart: Playwright keeps the original Worker object.
const restartedWorker = await extensionContext.waitForEvent('serviceworker');That wait is appropriate for a genuinely new service-worker registration and for initial discovery when no worker exists. It is not an MV3 wake-up signal. A test that waits for it after an idle period can time out even though the original Worker handle is ready for a new evaluation.
Avoid hard sleeping for 30 seconds to force suspension in every product test. The approximate idle policy can change, and sleep adds fixed suite latency without proving a user outcome. Keep any explicit suspension compatibility test small, Chromium-specific, and separate from ordinary popup or content-script coverage.
Diagnose startup, discovery, and execution as separate failures
When CI says it cannot find the worker, capture launch facts before editing the extension code. Start with the package-local version and the exact project:
npx playwright --version
EXTENSION_PATH="$PWD/extension" \
npx playwright test tests/extension-smoke.spec.ts \
--project=chromium \
--reporter=line \
--trace=retain-on-failureThe chromium project name in this command is a project selector, not the same thing as the channel: 'chromium' launch option in the fixture. Make sure the configured project exists, and inspect the fixture for the channel choice.
Attach a small inventory when discovery fails. It should include URLs, not arbitrary extension storage or profile files:
const workers = extensionContext.serviceWorkers().map(worker => worker.url());
await testInfo.attach('service-workers.json', {
body: Buffer.from(JSON.stringify({ workers }, null, 2)),
contentType: 'application/json',
});Once the worker is identified, capture its console messages around the action under investigation. Worker console events are available from Playwright 1.57, and the handler should be removed when the fixture or diagnostic step finishes.
import type { ConsoleMessage, TestInfo, Worker } from '@playwright/test';
export async function captureWorkerConsole(
worker: Worker,
testInfo: TestInfo,
run: () => Promise<void>,
): Promise<void> {
const messages: Array<{ type: string; text: string }> = [];
const onConsole = (message: ConsoleMessage) => {
messages.push({ type: message.type(), text: message.text() });
};
worker.on('console', onConsole);
try {
await run();
} finally {
worker.off('console', onConsole);
await testInfo.attach('extension-worker-console.json', {
body: Buffer.from(JSON.stringify(messages, null, 2)),
contentType: 'application/json',
});
}
}This recorder catches console output emitted after subscription. It cannot recover startup messages that occurred before the fixture found the worker, so an empty attachment does not prove the worker executed no code. Add a small extension-owned readiness signal when startup itself is the contract.
Console text can contain page URLs, payload fragments, or tokens written by careless debug code. Keep collection scoped to a failing action, apply the report's normal access controls, and remove verbose extension logging after the defect is understood. More log volume is not the same as better evidence.
Do not fail a test merely because the worker wrote to console.error. Some extensions intentionally report recoverable remote-service failures and continue with local behavior. Assert the user-facing fallback or a specific extension-owned error contract, then keep console output as supporting evidence. Conversely, a quiet console does not prove success; the worker may fail before its logger initializes or handle an error without logging it.
Interpret the result by boundary:
- An empty list after the expected startup point suggests the extension did not load, the path is wrong, or Chromium rejected its manifest.
- Only
http:orhttps:worker URLs suggest the test found application service workers but not the extension worker. - A
chrome-extension:worker with an unexpected host suggests the wrong unpacked extension or more than one extension was loaded. - The expected worker plus a failing popup navigation points to the declared extension page, permissions, or extension code rather than launch.
- The exact
Service worker restartedtext on an in-flight evaluation identifies the MV3 suspension edge described above.
Run headed for a local diagnosis when the extension itself reports startup errors in Chromium UI:
EXTENSION_PATH="$PWD/extension" \
npx playwright test tests/extension-smoke.spec.ts \
--project=chromium \
--headed \
--debugHeaded mode is a diagnostic change, not a CI fix. If headed passes and the documented headless chromium channel fails, compare the resolved Playwright version, channel, extension build output, and launch arguments. Do not permanently switch CI to a display server before proving which boundary differs.
The trace is strongest for extension pages and content-script interactions. A service-worker inventory and the failure stack are stronger for worker discovery. If the browser process exits, retain runner logs as well. One artifact rarely explains every layer of an extension test.
The near-miss that wastes the most time is a web service worker. Playwright exposes both through BrowserContext.serviceWorkers(). Always inspect the URL scheme before applying an extension diagnosis. A PWA worker can be healthy while the extension was never installed.
The extension worker exists in a different context
Another worker-discovery timeout can look almost identical to a failed extension launch. A suite introduces extensionContext, but an older helper still requests the built-in context fixture or receives the built-in page. The persistent context successfully loads the extension and owns its worker. The helper waits on a separate, ordinary context that will never emit that extension's service-worker event.
This defect survives several superficial checks. Both contexts run Chromium. The Playwright project can still be named chromium. The ordinary context may even contain an http: service worker from the application, so a nonempty serviceWorkers() result is not proof that the helper reached the extension context.
Capture inventories for the two context objects under different attachment names. In a healthy run, the extension context's workers field contains a chrome-extension: URL whose path matches the background worker declared by the built manifest. A wrong-context run shows that same expected URL in the extension context inventory while the context used by the failing helper has an empty list or only web schemes. A launch failure differs again: the expected extension URL is absent from the persistent context itself.
Page ownership supplies a second check for popup and content-script tests. A page created with extensionContext.newPage() must report that persistent context from page.context(). If the comparison is false, the test is exercising a normal isolated page even if its URL and browser name look familiar. Make the helper accept the extension page or extension context explicitly. Do not teach it to search every live context and take the first extension worker, because that makes ownership depend on startup order.
The misleading field here is worker count. A value of one can mean one expected extension worker, one unrelated PWA worker, or one other unpacked extension. Read the URL scheme, host, and path together. The host identifies the installed extension within that run, while the path can be checked against the built manifest. Keep the raw URLs sanitized if a development build appends information your report policy should not retain.
Keep persistent profiles isolated in parallel CI
A persistent context stores browser state in its user-data directory. Reusing one fixed path such as /tmp/test-user-data-dir across parallel workers creates contention and lets extension state survive between tests. The path is part of test isolation, not a disposable naming detail.
testInfo.outputPath('chromium-profile') produces a path inside that test's output area, which naturally separates retries and parallel workers. Close the context before the fixture ends so Playwright and Chromium release the profile cleanly. Do not launch two persistent contexts against the same directory at the same time.
A CI job needs bundled Chromium and the extension's built output. The relevant wiring can stay small:
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build the unpacked extension
run: npm run build:extension
- name: Run extension tests
env:
EXTENSION_PATH: ${{ github.workspace }}/dist/extension
run: npx playwright test tests/extension --project=chromiumUse the repository's actual package manager and build command. The important dependency order is browser installation, extension build, then tests. The fixture should fail immediately when manifest.json is absent, which distinguishes a build artifact problem from a worker event timeout.
Uploading an entire persistent profile for routine failures is usually a poor trade. Profiles can be large and may contain browsing history, cookies, extension storage, and other sensitive material. Retain the Playwright report, trace, screenshots, worker URL inventory, and extension build manifest first. Capture a profile only for a narrowly controlled investigation with an appropriate retention policy.
Worker-scoped reuse can reduce launch latency for a large suite. It also couples tests through chrome.storage, IndexedDB, permissions, open tabs, alarms, and whatever state the extension owns. Clearing one storage API does not prove the whole profile is clean. If reuse is necessary, group tests serially around an explicit reset contract and keep a fresh-profile smoke project in CI to catch installation defects.
Sharding adds another rule: each shard must build or download the same extension revision. Record the extension commit or build identifier in the report. A Playwright version match cannot compensate for shard A testing yesterday's extension bundle while shard B tests today's.
Migrate an existing extension suite in dependency order
Add the persistent-context smoke test beside the existing suite before moving behavior tests. Its first job is to prove the built manifest, expected worker URL, runtime identity, and fixture teardown in the same CI image that will run the migration. Keep the old test path active during this stage so a fixture wiring failure does not silently remove release coverage.
Next, make one fixture module the import boundary for extension tests and migrate a single popup specification. Tests that still request the built-in page or context are likely to break first. Treat those failures as useful ownership findings. Change the fixture source without changing the product assertions, then confirm the popup page belongs to extensionContext before adding more files.
Move content-script scenarios after popup behavior is stable. They add a site origin, manifest match rules, and page-side evidence, so migrating them at the same time as browser launch makes a missing warning difficult to classify. Keep worker discovery, extension-page behavior, and content-script injection as separate CI targets until each has a distinct failure signal.
Profile parallelism comes last. Give every concurrently running persistent context its own user-data directory, then run the migrated group at the intended worker and shard layout. Existing tests that relied on extension storage left by an earlier case will fail at this point. Repair those dependencies with explicit setup rather than reusing the old profile. Restore any broader retry policy only after the first-attempt failures are understandable.
The rollout is working when the smoke test finds the expected worker in the extension context, migrated pages report the same owner, and a discovery failure includes inventories that distinguish absent launch from wrong context. Product failures should now reach popup, messaging, or content-script assertions instead of ending as an unclassified worker timeout. A passing retry is not sufficient evidence, since a retry also creates a different profile and can mask state dependence.
There is a specific transition cost. Running old and new smoke paths together temporarily repeats browser startup. A test-scoped persistent context creates a Chromium process and profile directory for each test, which adds launch latency, filesystem activity, and concurrent browser demand. Worker reuse reduces those costs but requires a reset contract for every extension-owned state surface. Measure the real queue and runtime effect before choosing that maintenance burden.
The extension team owns the built manifest, background worker entry, and any extension-owned readiness signal. Test-platform maintainers own the persistent-context fixture, context identity checks, and profile disposal. CI maintainers own the bundled Chromium installation and distribution of the exact same build output to every shard. A handoff between them should include the resolved extension path, manifest version and declared worker path, Playwright version and launch channel, worker URL inventories labeled by context, the page ownership result, shard identity, and extension build identifier. Do not attach the entire profile as routine evidence.
Fresh-profile automation does not catch extension upgrade migrations. It cannot prove that storage, permissions, rules, or alarms created by a previously installed release survive or migrate correctly when the new package replaces it. That needs a separate upgrade scenario that begins with the prior release in a controlled profile, performs the supported update path, and verifies the migrated state. The ordinary isolated suite should remain fresh because upgrade state would otherwise contaminate unrelated behavior tests.
Know when a real extension browser is the wrong layer
Use the persistent-context setup for integration behavior that depends on extension installation: runtime messaging, extension pages, content scripts, permissions, and interaction with the tested site. It is expensive and Chromium-specific, so it should not absorb every unit case.
Keep pure parsing, rule evaluation, URL classification, and state-reducer tests outside the browser when those modules can be called directly. They run faster, produce clearer expected and actual values, and do not depend on a service-worker lifecycle. A smaller number of browser tests can then prove that the extension wires those modules into Chrome correctly.
Do not use extension tests to validate a normal site's generic service-worker caching. Playwright has separate service-worker APIs and guidance for that problem. The chrome-extension: scheme, persistent context, and side-load flags add variables that a PWA test does not need.
Avoid automating branded Chrome or Edge with removed side-load flags and then loosening security settings until the test passes. That no longer represents the documented Playwright extension path. Use bundled Chromium for unpacked-extension automation, and cover store-installed branded-browser behavior through a separately designed release check if the product requires it.
Do not treat worker existence as the final assertion. A worker can load while the popup is broken, the content script does not match the target origin, or message handling returns the wrong state. Keep the smoke test, then assert a visible or queryable extension outcome.
Finally, do not hide Service worker restarted under an unlimited retry. One retry for a proven idempotent read handles the documented suspension edge. Repeated retries turn a genuinely broken worker into a slow timeout and erase the first useful error. Preserve the interrupted operation, classify whether replay is safe, and make the extension's commands idempotent where recovery matters.
// 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.
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.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why is my Chrome extension missing when Playwright starts?
Extensions require Chromium launched with a persistent context and the correct load arguments. Current Playwright guidance also uses bundled Chromium because branded Chrome and Edge removed the command-line flags used to side-load extensions.
How do I get a Manifest V3 extension ID in Playwright?
Find the extension service worker in `context.serviceWorkers()` or wait for the `serviceworker` event if it is not present yet. Parse the host from its `chrome-extension://` URL instead of assuming the first worker belongs to your extension.
Does Playwright emit another serviceworker event after MV3 idle suspension?
No. Playwright keeps the same `Worker` object across the extension worker's idle restart, so waiting for a second event will hang. A new evaluation waits for the restarted execution context to become ready.
What causes the Service worker restarted error?
An evaluation already in flight at the moment the MV3 worker suspends can fail with that message. Retry only an idempotent diagnostic read, and do not blindly replay an extension command that may already have changed state.
Should extension tests share one persistent profile in CI?
Fresh profiles give stronger isolation and avoid parallel processes competing for one user-data directory. Reusing a worker-scoped profile is faster, but extension storage, permissions, and background state can leak between tests.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test Reduced Motion with Playwright
Use Playwright reduced motion testing with media emulation to verify static alternatives, disabled animations, usable content, and regression checks in CI.
GUIDE 02
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 03
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Test WebAuthn Passkey Registration with Playwright
Master Playwright WebAuthn passkey registration testing with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.
GUIDE 05
Test WebSocket Subprotocol Negotiation with Playwright
Learn Playwright WebSocket subprotocol testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.