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). Use as the manifest-surface reference when authoring extension tests across both browsers.
manifest-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 the per-tool wrappers in this plugin (web-ext-cli-mozilla, chrome-extension-test-loader, playwright-extension-fixtures) and the two builders (mv2-to-mv3-migration-test-checklist, extension-storage-test-author).
For Playwright-driven MV3 popup / content-script fixtures see qa-modern-web/browser-extension-tests. That skill is a Chromium-only, popup + content-script + service- worker-fixture skill. This reference is browser-agnostic, manifest- field-keyed, and covers the Firefox column explicitly.
When to use
The manifest fields that change between MV2 and MV3
Per Update the manifest and Manifest V3 migration overview:
| 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 |
background field - exact shape
MV2 (per Update the manifest and Migrate to a service worker):
{
"background": {
"scripts": ["backgroundContextMenus.js", "backgroundOauth.js"],
"persistent": false
}
}MV3 (per Migrate to a service worker):
{
"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
Per Update the manifest:
"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
Per Update the manifest:
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.
Service-worker runtime restrictions (MV3-only test surface)
Per Migrate to a service worker, the background context in MV3 is a service worker - not a persistent page - and inherits the standard service-worker constraints plus a few extension-specific ones:
| 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 extension-storage-test-author) |
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 |
Quote from Migrate to a service worker:
"Registering a listener asynchronously (for example inside a promise or callback) is not guaranteed to work in Manifest V3."
"[Service workers] are ephemeral, which means they'll likely start, run, and terminate repeatedly."
The keep-alive heartbeat 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).
Offscreen documents
Per Migrate to a service worker, 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.
Firefox vs Chrome manifest key matrix
Per MDN manifest.json keys reference, the table below enumerates every documented manifest key with its Firefox / Chrome availability and MV2 / MV3 status. Tests must either gate on browser detection or split into per-browser fixtures when the key appears as "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
Per MDN manifest.json keys reference, 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 ([addons.mozilla.org]) submission validation requires a stable gecko.id for signing - a test asserting that the built zip carries a deterministic ID prevents accidental ID drift across builds.
MV3 platform availability
Per Manifest V3 migration overview: "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 itself 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 per cr-mig-manifest |
Testing background.scripts array under MV3 | Field doesn't exist in MV3; manifest fails to load | Use background.service_worker single string per cr-mig-sw |
Using localStorage in service-worker tests | Throws in MV3 | Use chrome.storage.local (see extension-storage-test-author) |
Registering chrome.runtime.onMessage inside a promise | Listener may not fire after worker restart in MV3 | Register synchronously at top level per cr-mig-sw |
Testing flat-string web_accessible_resources under MV3 | Resources unreachable from page context | Use object form { resources, matches } per cr-mig-manifest |
| Treating Firefox MV3 as Chrome MV3 | externally_connectable, offline_enabled not supported; browser_specific_settings required | Run Firefox tests with web-ext (see web-ext-cli-mozilla) and gate Chrome-only assertions |
Using setTimeout to delay background work | Cancelled on worker termination | Use chrome.alarms.create per cr-mig-sw |
| Polling via 1s heartbeat to keep SW alive | Chrome explicitly limits keepalive abuse | Restructure to event-driven; offscreen document for long DOM work |