Chrome extension offscreen document testing matters when a Manifest V3 service worker needs DOM or window capabilities without opening a visible tab. The hidden document may parse HTML, work with blobs, migrate local storage, use the clipboard, or support another documented reason. If its lifecycle is wrong, users can see duplicate work, lost messages, stale state, or a feature that fails only after the service worker restarts.
This tutorial builds a disposable extension that sends synthetic HTML to one offscreen document, extracts a small summary with DOMParser, and returns the result through runtime messaging. The QA goal is to prove create, reuse, close, and recreate behavior under concurrency and restart—not merely to see one successful response.
What Chrome officially documents
The current chrome.offscreen API reference says offscreen documents give Manifest V3 extensions DOM access in a hidden document. The manifest needs the offscreen permission. The document URL must be a static HTML file bundled with the extension, it cannot receive focus, and its window.opener is always null.
An installed extension can normally have only one offscreen document open at a time. Split-mode incognito can have one in the normal profile and one in the incognito profile. Chrome also limits offscreen documents to the runtime extension API, so service-worker communication must use runtime messaging.
createDocument() requires a URL, one or more documented reasons, and a developer justification. Chrome’s lifecycle example checks existing offscreen contexts and uses one shared creation promise to avoid concurrent creation. closeDocument() explicitly closes the active document.
Define the QA contract
Use a synthetic parser task with no browsing data or production content:
For every approved input ID, return exactly one normalized DOM summary. At most one offscreen document may exist per eligible profile. Concurrent requests must share creation, each response must match its request, close must remove the context, and the next request must recreate a clean document.
Record the extension build, browser build, profile mode, extension ID, test ID, input hash, lifecycle events, message correlation ID, output, and user-visible result. Never log full page content, clipboard contents, credentials, or personal data.
Step 1: create the smallest Manifest V3 fixture
Use a disposable unpacked extension with manifest.json, service-worker.js, offscreen.html, and offscreen.js. Add only the required permission:
{
"manifest_version": 3,
"name": "Offscreen Lifecycle Lab",
"version": "1.0.0",
"background": { "service_worker": "service-worker.js" },
"permissions": ["offscreen"]
}
Keep the fixture isolated from host permissions and external services. The input should be a hard-coded synthetic HTML string, such as a heading, two links, and a deliberately malformed tag. That makes expected output deterministic.
Step 2: guard document creation
Use chrome.runtime.getContexts() to look for the exact offscreen URL before creating it. Keep one shared promise so two near-simultaneous requests do not both call createDocument():
let creating;
async function ensureOffscreen(path) {
const url = chrome.runtime.getURL(path);
const contexts = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [url]
});
if (contexts.length) return 'reused';
if (!creating) {
creating = chrome.offscreen.createDocument({
url: path,
reasons: ['DOM_PARSER'],
justification: 'Parse synthetic QA fixture markup'
}).finally(() => { creating = null; });
}
await creating;
return 'created';
}
The returned label is test instrumentation, not a Chrome guarantee. Capture it with a correlation ID, then verify the actual context count. Do not rely on a global variable for durable business state because extension service workers are ephemeral.
Step 3: keep messaging explicit
After ensureOffscreen() resolves, send a runtime message containing a request ID, target, operation, and sanitized payload. The offscreen listener should ignore messages for other targets, parse the fixture, and return only the expected fields.
{
"requestId": "OFF-007",
"target": "offscreen",
"operation": "summarize-fixture",
"html": "<h1>QA</h1><a href='/a'>A</a>"
}
Validate request ID, allowed operation, payload size, and response schema. Treat HTML as data: do not execute inline scripts or return arbitrary markup. The final response can contain heading text, link count, parse warnings, and the same request ID.
Step 4: prove the single-document invariant
Trigger 20 requests together from the service worker. Add unique correlation IDs but use the same offscreen URL. Expected results:
- one successful creation path
- the other requests wait for or reuse that document
- one active matching context after setup
- 20 responses with no missing or duplicated IDs
- no uncaught duplicate-document error
- all normalized summaries match the deterministic oracle
Repeat the burst after a clean browser restart and after forcing a service-worker restart. Chrome’s migration guidance explains that service workers terminate when unused and that state should not depend on globals. The creation guard must recover from a fresh worker while still detecting an existing offscreen document.
Step 5: test close and recreate
Call chrome.offscreen.closeDocument() only after all in-flight requests finish. Query contexts until the document is absent, then send a new request. It should create a fresh document and return the correct result.
| Stage | Expected context | Expected result |
|---|---|---|
| Before first request | None | No output |
| After setup | One matching document | Parsed summary |
| Concurrent burst | Still one | Every ID returned once |
| After close | None | No new messages accepted |
| Next request | One new document | Fresh correct summary |
Add a race where close begins while a request is pending. Your application must define whether to drain, reject, or retry. Assert that behavior explicitly; do not accept random success.
Step 6: test invalid configuration
- Remove the
offscreenpermission. - Point the URL to a missing file.
- Use a URL that is not bundled with the extension.
- Send an unsupported operation.
- Send malformed and oversized payloads.
- Call an extension API other than runtime inside the offscreen document.
- Send a message before creation resolves.
- Close when no document is active.
For every case, require a bounded error with request ID, phase, safe message, and no leaked payload. Check the extension service-worker console and the offscreen context separately because errors may surface in different places.
Step 7: validate lifecycle reasons
Use the reason that matches the real DOM capability. For this lab, DOM_PARSER is clearer than a broad or unrelated reason. Chrome documents different reasons and notes that the audio-playback reason has a special inactivity lifetime, while other reasons do not define that same automatic timeout.
Test the actual production reason in a separate matrix. A passing parser test does not validate clipboard permission behavior, media prompts, iframe restrictions, or audio lifetime.
Step 8: automate without overfitting to internals
Chrome’s extension end-to-end testing guide recommends testing user-visible behavior and avoiding unnecessary dependence on internal state. For this hidden lifecycle, use internal context checks as targeted diagnostic assertions, but keep the primary assertion on the feature result a user receives.
Load the unpacked extension in a clean test profile, keep a stable test extension ID where needed, and exercise the same trigger a user uses. Capture internal lifecycle events only in test builds. Do not ship verbose payload logging.
Step 9: test service-worker termination realistically
Some automation frameworks can keep extension service workers alive because a debugger is attached. Chrome explicitly warns about this. Include a termination-capable test path and a manual idle-recovery check so your suite does not create false confidence.
After termination, the new worker should reconstruct state from supported storage, query the existing offscreen context, and continue without creating a duplicate. If the offscreen document was closed too, the next request should recreate it cleanly.
Release checklist
- Offscreen permission and purpose are justified
- Document URL is static and packaged
- Only runtime messaging is used for extension communication
- Creation checks the exact context and serializes races
- At most one document exists per eligible profile
- Requests and responses use unique IDs and schemas
- Close behavior handles in-flight work deterministically
- Restart recovery does not rely on worker globals
- Malformed, oversized, and unsupported messages fail safely
- Logs exclude page content, secrets, and personal data
- Automated and real termination paths are both covered
- User-visible outcomes remain the release authority
Official sources
- Chrome for Developers: Offscreen API
- Chrome for Developers: Migrate to a service worker
- Chrome for Developers: Extension end-to-end testing
- Chrome for Developers: Extension service-worker lifecycle
- Chrome for Developers: Test service-worker termination
Offscreen documents solve a real Manifest V3 gap, but hidden does not mean simple. A dependable QA strategy tests lifecycle state, concurrent creation, correlated messaging, closure, restart recovery, privacy, and user-visible behavior together.
