manifest-v3-test-surface-reference
Pure-reference catalog of the Manifest V3 test surface for Firefox + Chromium browser extensions. Maps each manifest field that changed from MV2 (manifest_version, background.service_worker vs background.scripts, action vs browser_action / page_action, host_permissions split, web_accessible_resources object-form, content_security_policy object-form), the runtime restrictions service workers impose (no DOM, no XMLHttpRequest, no localStorage, ephemeral lifecycle, synchronous listener registration, alarms instead of setTimeout), and the Firefox-vs-Chrome key matrix (browser_specific_settings.gecko, externally_connectable / offline_enabled gaps, MV2-only user_scripts manifest key); references/ carry Mozilla's web-ext CLI (lint via addons-linter, run on firefox-desktop / firefox-android / chromium targets, deterministic build, AMO sign). Use as the manifest-surface reference when authoring extension tests across both browsers, or when driving Firefox runs / AMO signing with web-ext.
Install with skills.sh (any agent)
npx skills add testland/qa --skill manifest-v3-test-surface-referencemanifest-v3-test-surface-reference
Overview
Manifest V3 (MV3) is the current packaging contract for Chromium-family browser extensions; Firefox supports it as a peer with a documented divergence list. The manifest is the only declarative input both browsers see - every test surface (background lifecycle, permission prompt, content-script injection, web-accessible-resource fetch) hangs off a manifest field. Knowing which field maps to which runtime behaviour is what lets a test author decide whether a behaviour is testable in unit, integration, or full-browser scope.
This skill is the pure reference consumed by playwright-extension-fixtures (and its reload-matrix / storage-tests references). The summary tables below are the quick lookup; exact MV2/MV3 field shapes, the full Firefox/Chrome key matrix, and the offscreen-document
For Playwright-driven MV3 popup / content-script fixtures and assertion recipes see playwright-extension-fixtures (Chromium-only; references/extension-surface-tests.md). This reference is browser-agnostic, manifest-field-keyed, and covers the Firefox column explicitly.
When to use
Manifest fields that change between MV2 and MV3
Summary lookup; the exact MV2/MV3 shapes for background, host_permissions, and web_accessible_resources are in references/manifest-matrix.md.
| Field | MV2 | MV3 | Test implication |
|---|---|---|---|
manifest_version | 2 | 3 | First assertion in any conformance test |
background | { "scripts": [...], "persistent": false } | { "service_worker": "sw.js", "type": "module"? } | Lifecycle test moves from "always running" to "wake on event, terminate idle" |
action / browser_action / page_action | browser_action or page_action | action (unified) | Popup-rendering tests target the unified action slot |
permissions | API + host strings mixed | API strings only | API-permission tests stay; host-permission tests move (see next row) |
host_permissions | (did not exist) | match patterns moved here from permissions | Each host pattern is a runtime permission prompt - testable as a user gesture flow |
optional_host_permissions | (did not exist) | runtime-requestable hosts | Tests must drive permissions.request from a user gesture |
web_accessible_resources | flat string array | array of { resources: [...], matches: [...] } objects | Cross-origin fetch of an extension resource is only allowed from a matching matches pattern |
content_security_policy | string | object with extension_pages / sandbox keys | No inline <script>, no remotely hosted code, no eval - testable via load-time CSP violations |
Service-worker runtime restrictions (MV3-only test surface)
The MV3 background context is a service worker - not a persistent page - and inherits the standard service-worker constraints plus a few extension-specific ones. The offscreen-document construct and the keep-alive heartbeat are detailed in references/manifest-matrix.md.
| Constraint | MV2 | MV3 | Test implication |
|---|---|---|---|
DOM / window | available | unavailable | Anything touching DOM moves to an offscreen document (chrome.offscreen.createDocument) |
XMLHttpRequest | available | unavailable - use fetch() | XHR-using test fixtures must be rewritten |
localStorage | available | unavailable - use chrome.storage.local | Tests asserting persisted state must use chrome.storage.* (see playwright-extension-fixtures references/storage-tests.md) |
setTimeout / setInterval | reliable | cancelled when worker terminates - use chrome.alarms | Tests timing background work must use alarms, not timers |
| Listener registration | top-level or async | must be synchronous at top level | Async-registered listeners are "not guaranteed to work in Manifest V3" |
| Lifecycle | persistent | ephemeral (start -> run -> terminate, repeated) | Globals reset; storage is source of truth |
Two load-bearing quotes from cr-mig-sw (opens in new window): "Registering a listener asynchronously (for example inside a promise or callback) is not guaranteed to work in Manifest V3," and "[Service workers] are ephemeral, which means they'll likely start, run, and terminate repeatedly."
Firefox vs Chrome manifest key matrix
The full 23-key availability matrix (MV2 / MV3 x Firefox / Chrome) and the browser_specific_settings.gecko example are in references/manifest-matrix.md. The divergences that most often break a cross-browser test:
MV3 platform availability
Manifest V3 is supported generally in Chrome 88 or later, with some replacement APIs landing after 88. minimum_chrome_version in the manifest pins a floor for users on stable channels.
The MV2 deprecation timeline lives on a separate Chrome "Manifest V2 support timeline" page - cite by stable URL (developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) and read live, as dates have shifted multiple times.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Assuming MV3 permissions still accepts host match patterns | Lint passes; runtime drops them silently | Move all host patterns to host_permissions |
Testing background.scripts array under MV3 | Field doesn't exist in MV3; manifest fails to load | Use background.service_worker single string |
Using localStorage in service-worker tests | Throws in MV3 | Use chrome.storage.local (see playwright-extension-fixtures references/storage-tests.md) |
Registering chrome.runtime.onMessage inside a promise | Listener may not fire after worker restart in MV3 | Register synchronously at top level |
Testing flat-string web_accessible_resources under MV3 | Resources unreachable from page context | Use object form { resources, matches } |
| Treating Firefox MV3 as Chrome MV3 | externally_connectable, offline_enabled not supported; browser_specific_settings required | Run Firefox tests with web-ext (see references/web-ext-firefox.md) and gate Chrome-only assertions |
Using setTimeout to delay background work | Cancelled on worker termination | Use chrome.alarms.create |
| Polling via 1s heartbeat to keep SW alive | Chrome explicitly limits keepalive abuse | Restructure to event-driven; offscreen document for long DOM work |
Limitations
References
Manifest matrix + field shapes - manifest-v3-test-surface-reference
View source (opens in new window)Manifest matrix + field shapes - manifest-v3-test-surface-reference
Deep-lookup detail for the summary tables in SKILL.md: exact MV2/MV3 field shapes, the full Firefox/Chrome key matrix, and the two service-worker constructs (offscreen documents, keep-alive). Sources are consolidated in the link list at the bottom.
Exact field shapes
background
MV2:
{
"background": {
"scripts": ["backgroundContextMenus.js", "backgroundOauth.js"],
"persistent": false
}
}MV3:
{
"background": {
"service_worker": "service_worker.js",
"type": "module"
}
}The MV3 service_worker field is a single string (not an array); type is optional and only valid as "module". The MV2 persistent flag is removed entirely.
host_permissions split
"Host permissions in Manifest V3 are a separate field; you don't specify them in
"permissions"or in"optional_permissions"."
MV2:
"permissions": ["tabs", "bookmarks", "https://www.blogger.com/"],
"optional_permissions": ["unlimitedStorage", "*://*/*"]MV3:
"permissions": ["tabs", "bookmarks"],
"optional_permissions": ["unlimitedStorage"],
"host_permissions": ["https://www.blogger.com/"],
"optional_host_permissions": ["*://*/*"]"content_scripts[].matches" is unchanged between MV2 and MV3.
web_accessible_resources shape change
MV2 (flat string array):
"web_accessible_resources": [
"images/*",
"style/extension.css",
"script/extension.js"
]MV3 (array of objects, each scoping resources to URL patterns or extension IDs):
"web_accessible_resources": [
{ "resources": ["images/*"], "matches": ["*://*/*"] },
{
"resources": ["style/extension.css", "script/extension.js"],
"matches": ["https://example.com/*"]
}
]Test implication: a page-context fetch(chrome.runtime.getURL('...')) that worked under MV2 may 404 under MV3 if the requester's origin isn't covered by a matches pattern.
Firefox vs Chrome manifest key matrix
Every documented manifest key with Firefox / Chrome availability and MV2 / MV3 status per mdn-manifest (opens in new window). Tests must gate on browser detection or split into per-browser fixtures when a key is "not supported" in one column.
| Key | MV2 | MV3 | Firefox | Chrome | Test note |
|---|---|---|---|---|---|
manifest_version | yes | yes | yes | yes | Mandatory; first assertion |
name | yes | yes | yes | yes | Mandatory |
version | yes | yes | yes | yes | Mandatory; semver in Firefox AMO |
action | no | yes | yes | yes | Unified popup slot |
browser_action | yes | no | yes (MV2 only) | yes (MV2 only) | Renames to action in MV3 |
page_action | yes | no | yes (MV2; different in MV3) | yes (MV2 only) | Firefox keeps a different page-action shape |
background | yes | yes | yes | yes | Shape changes per above |
browser_specific_settings | yes | yes | yes | no | Tests asserting extension ID stability rely on this in Firefox |
content_scripts | yes | yes | yes | yes | Same shape |
content_security_policy | yes | yes | yes | yes | Shape changes (object in MV3) |
declarative_net_request | yes | yes | yes | yes | Replaces webRequest-blocking surface |
externally_connectable | yes | yes | no | yes | Cross-origin runtime messaging - Chrome only |
host_permissions | no | yes | yes | yes | New in MV3; permission-prompt test surface |
offline_enabled | yes | yes | no | yes | Chrome-only manifest key |
optional_host_permissions | no | yes | yes | yes | Runtime host grants |
optional_permissions | yes | yes | yes | yes | Runtime API grants |
permissions | yes | yes | yes | yes | API permissions only in MV3 |
protocol_handlers | yes | yes | yes (Firefox only) | no | Firefox-only register-protocol surface |
sidebar_action | yes | yes | yes (Firefox/Opera) | no | Sidebar UI - not in Chrome |
storage (as manifest key) | yes | yes | no | yes | Note: the storage API works in both |
theme_experiment | yes | yes | yes (Firefox only) | no | Experimental theming |
user_scripts (manifest key) | yes | no | yes (MV2 only) | yes (MV2 only) | MV3 replaces with userScripts API |
web_accessible_resources | yes | yes | yes | yes | Shape changes (object array in MV3) |
browser_specific_settings.gecko
Firefox-specific metadata (extension ID, min Firefox version) lives under browser_specific_settings.gecko. Chrome silently ignores this key:
{
"browser_specific_settings": {
"gecko": {
"id": "@addon-example",
"strict_min_version": "42.0"
}
}
}Test note: AMO submission validation requires a stable gecko.id for signing - a test asserting the built zip carries a deterministic ID prevents accidental ID drift across builds.
Service-worker constructs
Offscreen documents
DOM-requiring work in MV3 goes to an offscreen document:
chrome.offscreen.createDocument({
url: chrome.runtime.getURL('offscreen.html'),
reasons: ['CLIPBOARD'],
justification: 'testing the offscreen API',
});Offscreen documents communicate with the service worker via runtime.sendMessage / runtime.onMessage only - they don't share other extension APIs.
Keep-alive heartbeat
The keep-alive pattern (calling chrome.runtime.getPlatformInfo on a ~25s interval to reset the idle timer, or writing to chrome.storage.local every 20s) is documented but Chrome explicitly limits its use to enterprise/education managed extensions, "reserves the right to take action" against others, and notes a waitUntil()-style API is under discussion in the W3C WebExtensions Community Group (WECG).
web-ext - Mozilla's CLI for Firefox WebExtension testing
View source (opens in new window)web-ext - Mozilla's CLI for Firefox WebExtension testing
Companion reference for manifest-v3-test-surface-reference. Consult when the extension targets Firefox (signing is mandatory for distribution) or when cross-browser test runs need a single CLI that drives both Firefox and Chromium against the same source tree.
web-ext is Mozilla's reference CLI for WebExtension development. Per the mozilla/web-ext README (opens in new window) it bundles run, lint, sign, build, and docs; v8 added dump-config and reworked signing to use the AMO submission API by default (per the web-ext command reference (opens in new window)). --source-dir / -s is a global option; --artifacts-dir (default ./web-ext-artifacts) controls where build / sign write zips.
Install
npm install --save-dev web-ext # per-project, preferred for CI determinismMozilla recommends the current Node LTS (per we-readme (opens in new window)). A web-ext-config.cjs file pins flags per run; web-ext dump-config (v8, per we-cmd (opens in new window)) prints the resolved configuration as JSON for diffing.
Lint
web-ext lint wraps mozilla/addons-linter (opens in new window) and emits AMO-compatible warnings. CI-friendly invocation:
web-ext lint \
--source-dir ./dist \
--output json \
--pretty \
--warnings-as-errors > lint-report.json--warnings-as-errors / -w is the right CI default - per we-cmd (opens in new window) it escalates lint warnings (unsupported manifest fields, MV2-only APIs flagged for MV3) into a non-zero exit code. Other flags: --metadata, --self-hosted (disables AMO-related messages - never on AMO-bound builds), --boring.
Output shape: { "count", "summary": { "errors", "notices", "warnings" }, "metadata", "errors": [{ "code", "message", "file", "line", "column" }], ... } (per addons-linter; spot-check its changelog before pinning a parser). Parse with jq '.summary.errors + .summary.warnings'.
Run
web-ext run builds the extension, installs it into a fresh temporary Firefox profile, then watches the source directory and reloads on edit (per we-cmd (opens in new window)):
web-ext run \
--source-dir ./dist \
--firefox=firefox \
--start-url 'about:debugging#/runtime/this-firefox'--firefox aliases (per we-cmd (opens in new window)): firefox, beta, nightly, deved / firefoxdeveloperedition, or a binary path. To pin a profile: --firefox-profile=qa-profile --profile-create-if-missing --keep-profile-changes (the last persists profile modifications across runs per we-cmd (opens in new window)).
Targets (per we-cmd (opens in new window) --target): firefox-desktop, firefox-android (wired via the --adb-* flag family), and chromium:
web-ext run --source-dir ./dist --target chromium --chromium-binary "$(which chromium)"The chromium target is the lowest-effort parity smoke-test; deep Chromium test surface lives in playwright-extension-fixtures.
Build
web-ext build -s ./dist -a ./build/artifacts --overwrite-dest \
--filename 'my-extension-{version}.zip'Per we-cmd (opens in new window), --filename defaults to {name}-{version}.zip; --overwrite-dest is required when the artefact path already exists. Build runs lint first and exits non-zero on lint failure.
Sign
Per we-cmd (opens in new window), web-ext sign v8 uses the AMO submission API by default and --channel is required:
export WEB_EXT_API_KEY='user:12345:1'
export WEB_EXT_API_SECRET='abcdef...'
web-ext sign -s ./dist --channel listed \
--amo-metadata ./amo-metadata.json \
--upload-source-code ./source.tar.gzChannel semantics (quoted from we-cmd (opens in new window)): with listed the extension "gets submitted for public listing"; with unlisted it "gets submitted for signing for self-distribution." Key flags: --api-key / --api-secret (JWT for the AMO API), --amo-metadata (required for a first listed version), --timeout (default 300000 ms), --approval-timeout (default 900000 ms, v8) - AMO can take longer in queue, requiring follow-up polling. Update submissions require an extension ID in the manifest (browser_specific_settings.gecko.id) per we-cmd (opens in new window). The signed .xpi lands in --artifacts-dir.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
web-ext sign without --channel | v8 requires it per we-cmd (opens in new window); command refuses to run | Always specify listed or unlisted |
Re-using a profile without --keep-profile-changes | Profile changes lost between runs; tests appear non-deterministic | Add the flag per we-cmd (opens in new window) |
web-ext lint without -w in CI | Warnings silently pass; AMO submission still rejects | --warnings-as-errors |
| Signing without an extension ID in the manifest | Update submission fails per we-cmd (opens in new window) | Add browser_specific_settings.gecko.id (see SKILL.md matrix) |
Committing web-ext-artifacts/ | Repo bloats with binaries | .gitignore + CI artefact upload |
Limitations
References
Related skills
chrome-extension-messaging-tests
Asserts Chrome extension message-passing behaviour against a running extension: one-shot `chrome.runtime.sendMessage` plus the literal `return true` that holds the response channel open for an async `sendResponse`, `chrome.tabs.sendMessage` into one tab's content script, long-lived `chrome.runtime.connect` ports and their `onDisconnect` triggers, web-page messages gated by `externally_connectable`, and `chrome.runtime.connectNative` native-messaging hosts. Covers the payload rules a test must respect (Chrome uses JSON serialization rather than structured clone, so `Map` / `Set` / `Date` do not round-trip; maximum message size is 64 MiB) and the first-listener-wins rule when several `onMessage` listeners are registered. Scope is messaging behaviour on an already-running extension, not the install or reload step. Use when a message reaches no listener, a `sendResponse` callback never fires, a port disconnects mid-test, or a page origin has to be proven allowed before publishing.
playwright-extension-fixtures
Author the Playwright fixture layer every Chromium extension test depends on - `chromium.launchPersistentContext` with `--disable-extensions-except=$DIR` + `--load-extension=$DIR`, the `channel: 'chromium'` selection that unlocks headless extension support, the `context.serviceWorkers()` + `waitForEvent('serviceworker')` race-handling pattern, and the `extensionId = serviceWorker.url().split('/')[2]` extraction recipe - plus, in references/, the extension load / reload matrix (which edit re-evaluates which surface), the `chrome.storage` test suite (area selection, quota-exceeded, `storage.onChanged`, managed read-only), and the per-surface assertion recipes (popup, content script, background messaging, MV3 auto-suspend survival). Use when authoring or debugging the fixture a Chromium extension test imports, when an edit appears to have no effect and you need the reload matrix, or when authoring popup / content-script / storage tests.