Testland
Browse all skills & agents

mv2-to-mv3-migration-test-checklist

Build-an-X workflow that emits a per-extension MV2 → MV3 migration test checklist. Walks the six canonical migration sections (manifest, service worker, API calls, declarative net request, security, publication) per the Chrome migration checklist, then for each one inventories the source MV2 manifest, names the MV3 replacement field / API, and emits the verification test cases. Covers the Firefox-Chrome divergence cells (page_action retained in Firefox, event pages allowed in Firefox 106+, host-permission install-prompt behavior changed in Firefox 127, web_accessible_resources `use_dynamic_url` Chromium-only). Output: a checklist artifact with per-section test cases the migrating extension must pass before publishing the MV3 build. Use when migrating an MV2 extension to Manifest V3 and the team needs section-by-section evidence the migration is complete.

Install with skills.sh (any agent)

npx skills add testland/qa --skill mv2-to-mv3-migration-test-checklist
View source

mv2-to-mv3-migration-test-checklist

Overview

MV2 -> MV3 migration silently breaks extensions in ways no single test catches: a removed background.scripts array fails to load, a leftover host pattern in permissions[] drops at runtime, a remote <script> tag passes lint but is blocked by the new CSP. It is testable only by walking the checklist section-by-section and emitting the verification case per cell - which is what this builder produces, mapping Chrome's official Manifest V3 migration checklist (opens in new window) to the test that proves each section landed. The per-section verification matrices and worked test code live in references/verification-cases.md.

Composes with:

  • manifest-v3-test-surface-reference - the field-level rename + key-matrix reference this builder consults at every step.
  • web-ext-cli-mozilla - the Firefox-side validator (web-ext lint) used to verify each MV3 cell against AMO.
  • chrome-extension-test-loader - the manual Chrome dev-load that surfaces section-level errors during checklist walk.

For Playwright-driven MV3 popup / content-script fixtures see browser-extension-tests. That skill assumes the MV3 manifest is already valid; this builder is what proves it before assertion-level tests run.

When to use

  • An MV2 extension is being migrated to MV3 - produce the per-extension verification checklist before the migration PR.
  • An MV3 build is failing to load with no clear error - walk the six checklist sections to localize.
  • A Chrome-MV3 extension is being ported to Firefox - the divergence cells flag which sections need Firefox-specific tests.
  • A reviewer wants section-by-section evidence the migration is complete.

Workflow

Step 1 - Inventory the source MV2 manifest

Read the project's manifest.json and snapshot the MV2 baseline per cr-manifest (opens in new window). The inventory drives the rest of the workflow - each MV3 checklist section consumes one or more fields from it.

# Snapshot the original
cp manifest.json manifest.mv2.backup.json
jq -r '
  {
    manifest_version: .manifest_version,
    background: .background,
    browser_action: .browser_action,
    page_action: .page_action,
    permissions: .permissions,
    optional_permissions: .optional_permissions,
    web_accessible_resources: .web_accessible_resources,
    content_security_policy: .content_security_policy,
    webRequestBlocking: (.permissions | index("webRequestBlocking") != null)
  }' manifest.json > mv2-inventory.json

Step 2 - Walk the six migration sections

For each section, author one test per checklist item. The full per-item verification matrices and the Section 1 manifest / DNR / CSP code are in references/verification-cases.md.

SectionWhat each test proves
1 - Update the manifestmanifest_version === 3; host patterns moved to host_permissions[]; WAR object-array shape
2 - Migrate to a service workerbackground.service_worker single string; no DOM / XHR / localStorage / timers in SW; synchronous listeners; state survives restart
3 - Update API callsscripting.* replaces tabs.executeScript / insertCSS; action replaces browser/page actions; web-ext lint clean
4 - Replace blocking web requestno webRequestBlocking in permissions[]; DNR rule_resources[] shape valid
5 - Improve extension securityno eval / arbitrary strings; no remote <script src>; CSP object shape without unsafe-eval
6 - Publicationbeta channel populated; gradual rollout; review-time planning; crash tracking
Firefox divergencepage_action retained; event pages (Firefox 106+); use_dynamic_url absent; host-permission prompts (Firefox 127)

The signature runtime test - the one migration "passes lint but breaks on first user" without - is the SW-survival probe. Keep it inline:

test('chrome.storage.local survives SW restart (35s idle)', async ({ context, extensionId }) => {
  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  await sw.evaluate(() => chrome.storage.local.set({ migrationProbe: 'value' }));
  await new Promise(r => setTimeout(r, 35_000)); // idle past SW timeout

  // Wake the SW with a no-op evaluate; storage must survive
  const value = await sw.evaluate(async () => {
    const { migrationProbe } = await chrome.storage.local.get('migrationProbe');
    return migrationProbe;
  });
  expect(value).toBe('value');
});

The 35-second figure is from manifest-v3-test-surface-reference (Chrome auto-suspends idle SWs after ~30s per cr-mig-sw (opens in new window)).

Step 3 - Emit the checklist artifact

Write the per-extension checklist to tests/migration-mv3-checklist.md:

# MV3 Migration Checklist - <extension-name>

Generated: <YYYY-MM-DD>
Source MV2 inventory: tests/mv2-inventory.json

## Section 1 - Update the manifest
- [ ] `manifest_version === 3` (test: manifest.spec.ts > "manifest_version is 3")
- [ ] No host patterns in `permissions[]` (test: manifest.spec.ts > "no host patterns left")
- [ ] `web_accessible_resources` object-array shape (test: manifest.spec.ts > "WAR object-array shape")

## Section 2 - Migrate to a service worker
- [ ] `background.service_worker` single string; no `scripts`, no `persistent`
- [ ] No `document.` / `window.` / `XMLHttpRequest` in SW source
- [ ] No `localStorage` in SW source
- [ ] Listeners registered at top level (no addListener inside async)
- [ ] No `XMLHttpRequest` anywhere
- [ ] Storage survives 35s idle (test: sw-survival.spec.ts)
- [ ] No `setTimeout` / `setInterval` in SW source
- [ ] Keep-alive (if present) gated on managed policy

## Section 3 - Update API calls
- [ ] No `tabs.executeScript` / `tabs.insertCSS` / `tabs.removeCSS`
- [ ] No `browser_action` / `page_action` (Chromium); `action` present
- [ ] No `chrome.extension.getBackgroundPage()`
- [ ] `web-ext lint` clean

## Section 4 - Replace blocking web request listeners
- [ ] No `webRequestBlocking` in `permissions[]`
- [ ] DNR `rule_resources[]` valid shape (if blocking needed)

## Section 5 - Improve extension security
- [ ] No `eval` / `new Function` / string-form setTimeout
- [ ] No remote `<script src=>` in extension HTML
- [ ] CSP is object shape, no `unsafe-eval`, no remote hosts

## Section 6 - Firefox divergences (skip if Chromium-only)
- [ ] `browser_specific_settings.gecko.id` present
- [ ] No `applications` key (renamed to `browser_specific_settings`)
- [ ] No `web_accessible_resources[].use_dynamic_url` (Chromium-only)
- [ ] `page_action` decision documented (Firefox retain / Chromium drop)
- [ ] Firefox 127+ install-prompt host-permission behavior tested

Verify: couple the checklist with a parallel Vitest / Playwright spec that automates each item, then gate CI on both files passing. If the checklist has a cell with no backing test, the migration is unproven for that cell - author the missing test before merging, never check the box by hand.

Anti-patterns

Anti-patternWhy it failsFix
One catch-all "MV3 migration" testSection regressions hide inside a green checkOne test per checklist item
Skip the 35s SW-survival testMigration "passes" lint but breaks on first userAlways include the timer test (Step 2)
Lint-only verificationweb-ext lint catches manifest issues, not runtime behaviorPair lint with a runtime spec
Treating Firefox + Chrome as one targetpage_action, event pages, host-prompt timing all differ per ff-mig (opens in new window)Per-browser fork per the divergence cells
Skipping CSP object-shape assertionMV2 flat-string CSP silently ignored by MV3 loader; falls back to permissive defaultAssert object shape (Section 5)
Asserting chrome.extension.getBackgroundPage() works in MV3API gone - returns undefined or throwsReplace with message passing
Allowing remote <script src> because lint passesLint doesn't always detect; CSP blocks at runtimeStatic-grep step (Section 5)

Limitations

  • Static-grep is heuristic. Tools like setTimeout("...") and eval can be obfuscated; the grep tests catch the common cases but should be paired with bundler-level AST scans for production confidence.
  • DNR rule semantics are not equivalence-tested against the old webRequest listeners - the checklist verifies shape, not that the new rules drop / modify the same requests. Behavior equivalence requires a separate test against representative traffic.
  • Firefox 126 vs 127 host-permission behavior is a hard split per ff-mig (opens in new window); the checklist treats 127+ as canonical and notes 126- as a documented caveat rather than a separate test cell.
  • web_accessible_resources.use_dynamic_url isn't covered by Firefox's web-ext lint rules (the key is Chromium-only per ff-mig (opens in new window)); the Firefox divergence test must check manifest shape directly.
  • Keep-alive policy enforcement is server-side. Per cr-mig-sw (opens in new window), Chrome "reserves the right to take action" against abusive keep-alives - there is no client-detectable signal, so the test only verifies the gate, not enforcement.

References

Verification cases - mv2-to-mv3-migration-test-checklist

View source (opens in new window)

Verification cases - mv2-to-mv3-migration-test-checklist

Per-item verification matrices and worked test code for the six migration sections plus the Firefox divergence cells. The skill's Step 2 walks these; the SW-survival core test lives inline in SKILL.md Step 2. Sources are in the link list at the bottom.

Section 1 - Update the manifest

Per cr-checklist (opens in new window), three items:

Checklist itemVerification test
"Change the manifest version number"Assert manifest_version === 3 in built dist/manifest.json
"Update host permissions"Assert no entry in permissions[] matches ^https?:// or starts with *://; all such entries moved to host_permissions[]
"Update web accessible resources"Assert web_accessible_resources is an array of objects with resources + matches keys (not flat string array)
import manifest from '../dist/manifest.json';
import { describe, it, expect } from 'vitest';

describe('MV3 manifest - Section 1', () => {
  it('manifest_version is 3', () => {
    expect(manifest.manifest_version).toBe(3);
  });

  it('no host patterns left in permissions[]', () => {
    const hostPattern = /^(\*|https?):\/\//;
    const stray = (manifest.permissions ?? []).filter((p: string) =>
      hostPattern.test(p)
    );
    expect(stray).toEqual([]);
  });

  it('web_accessible_resources is object-array shape', () => {
    const war = manifest.web_accessible_resources ?? [];
    for (const entry of war) {
      expect(typeof entry).toBe('object');
      expect(Array.isArray(entry.resources)).toBe(true);
      expect(Array.isArray(entry.matches) || Array.isArray(entry.extension_ids)).toBe(true);
    }
  });
});

Section 2 - Migrate to a service worker

Per cr-checklist (opens in new window), eight items; each maps to a runtime assertion against the loaded extension (not just the manifest). The SW-survival test for the "Persist states" item is in SKILL.md Step 2.

Checklist itemVerification test
"Update the background field in the manifest"Assert manifest.background.service_worker is a single string; scripts and persistent are absent
"Move DOM and window calls to an offscreen document"Static-grep SW source for document., window., XMLHttpRequest - non-zero matches = fail
"Convert localStorage to chrome.storage.local"Static-grep for localStorage. in SW source - non-zero matches = fail
"Register listeners synchronously"Static-grep for (async)?\s*[^.]\.addListener inside non-top-level scopes - flag any addListener inside an async body
"Replace calls to XMLHttpRequest() with global fetch()"Static-grep for XMLHttpRequest in extension source - fail if any
"Persist states"Smoke test: after 35s idle, re-read state from chrome.storage.local; assert it survives SW restart
"Convert timers to alarms"Static-grep for setTimeout/setInterval in SW source; fail if found
"Keep the service worker alive (in exceptional cases)"If a keep-alive ping is present, assert it's gated on managed-policy detection per cr-sw-alive (opens in new window)

Section 3 - Update API calls

Per cr-checklist (opens in new window), six items:

Checklist itemVerification test
"Replace tabs.executeScript() with scripting.executeScript()"Static-grep chrome.tabs.executeScript / browser.tabs.executeScript - fail if found
"Replace tabs.insertCSS() and tabs.removeCSS()"Static-grep `chrome.tabs.(insert
"Replace Browser Actions and Page Actions with Actions"Assert no manifest.browser_action or manifest.page_action; assert manifest.action present (Chromium) - see Firefox divergence
"Replace functions that expect a Manifest V2 background context"Manual review - assert source has no chrome.extension.getBackgroundPage()
"Replace callbacks with promises"Style item - chrome.* API calls in MV3 source should use await form
"Replace unsupported APIs"Run web-ext lint from web-ext-cli-mozilla; any MV3_UNSUPPORTED_API warnings = fail

The scripting namespace requires the "scripting" permission per cr-scripting (opens in new window); verify the inventory included it after migration.

Section 4 - Replace blocking web request listeners

Per cr-checklist (opens in new window), two items:

Checklist itemVerification test
"Update permissions"Assert no "webRequestBlocking" in permissions[] (still allowed for enterprise/policy extensions per cr-mig-overview (opens in new window), but most consumer extensions must drop it)
"Create declarative net request rules"If MV2 used chrome.webRequest.onBeforeRequest blocking, assert MV3 ships an equivalent declarative_net_request.rule_resources[] entry
test('DNR ruleset is well-formed', () => {
  const dnr = manifest.declarative_net_request;
  expect(dnr).toBeDefined();
  expect(Array.isArray(dnr.rule_resources)).toBe(true);
  for (const rs of dnr.rule_resources) {
    expect(typeof rs.id).toBe('string');
    expect(typeof rs.path).toBe('string');
    expect(typeof rs.enabled).toBe('boolean');
  }
});

Per cr-dnr (opens in new window), each rule resource must declare id, enabled, and path to a JSON file containing the rule array.

Section 5 - Improve extension security

Per cr-checklist (opens in new window), four items, all assertable against the built manifest + source:

Checklist itemVerification test
"Remove execution of arbitrary strings"Static-grep eval|new Function|setTimeout\(["'\]|setInterval(["'`]` - fail if any
"Remove remotely hosted code"Static-grep all extension HTML / JS for <script\s+src="https?://; only chrome-extension:// and relative paths allowed
"Update content security policy"Assert manifest.content_security_policy is the object shape ({ extension_pages, sandbox }), not a flat string
"Remove unsupported CSP values"Assert no unsafe-eval, no unsafe-inline for scripts, no remote script-src hosts in extension_pages
it('CSP is MV3 object shape with no unsafe-eval', () => {
  const csp = manifest.content_security_policy;
  expect(typeof csp).toBe('object');
  expect(typeof csp.extension_pages).toBe('string');
  expect(csp.extension_pages).not.toMatch(/unsafe-eval/);
  expect(csp.extension_pages).not.toMatch(/https?:\/\//); // no remote hosts
});

Per cr-mig-overview (opens in new window): "Manifest V3 removes support for remotely hosted code and execution of arbitrary strings."

Firefox divergence cells

Per ff-mig (opens in new window), Firefox MV3 diverges from Chrome MV3 in four observable ways; each gets its own conditional test, gated on target browser:

DivergenceFirefox MV3 behaviorTest
page_action retentionFirefox retains the separate page_action API and manifest keyIf manifest.page_action present, run Firefox tests but skip on Chromium
Event pages allowedFirefox supports non-persistent background pages from Firefox 106 onwardFirefox-only: background.scripts + persistent: false is valid (Chrome rejects)
web_accessible_resources.use_dynamic_urlFirefox does not support itAssert key absent when targeting Firefox
Host-permission install promptsFrom Firefox 127, host permissions in host_permissions and content_scripts are shown in the install prompt and granted on installationFirefox web-ext lint should not warn; smoke-test the install flow with web-ext run --target firefox-desktop

Notes from ff-mig (opens in new window): "if an extension update grants new host permissions, these are not shown to the user" - a test asserting "new host permissions trigger a re-prompt on update" will fail on Firefox; document as expected. Also rename the deprecated applications manifest key to browser_specific_settings, and ensure browser_specific_settings.gecko.id is set for Firefox AMO publication.

Section 6 - Publication rollout gates

Per cr-checklist (opens in new window) section 6 (publish):

Checklist itemVerification gate
"Publish a beta testing version"Chrome Web Store beta channel populated; AMO listed channel via web-ext sign --channel listed per web-ext-cli-mozilla
"Gradually roll out your release"Chrome Web Store percentage rollout configured (10% -> 50% -> 100% over >=3 days)
"Plan for review times"Calendar block: Chrome review p50 ~24h, p95 ~7d (cite the Chrome Web Store review timeline (opens in new window) live; figures move)
"Additional tips"Track crash reports via the developer dashboard for 7d post-rollout

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.

chrome-extension-test-loader

Loads an unpacked Chrome / Chromium extension for testing through the `chrome://extensions` Developer-mode flow: the minimum loadable `manifest.json`, the Load-unpacked directory-not-file selection, toolbar pinning, and the reload matrix deciding what a code edit actually re-evaluates (`manifest.json`, the background service worker, and content scripts need an explicit card refresh, content scripts additionally need a host-page refresh, while popup / options / other extension HTML pages re-evaluate on next open). Also covers reading the red Errors card, where service-worker and content-script logs surface, and the `--load-extension` and `web-ext --target chromium` equivalents that move the same load into CI. Scope is getting a build directory loaded and reloaded, not the runtime behaviour asserted afterwards. Use when a freshly built extension directory has to go into Chrome for the first time, or when an edit appears to have no effect and you need to know which surface requires an explicit reload.

extension-storage-test-author

Build-an-X workflow that emits a `chrome.storage` test suite. Picks the right area (`storage.local` 10 MB / `storage.sync` 100 KB total + 8 KB per item + 512 items + 1,800 writes/hour / `storage.session` 10 MB in-memory MV3-only / `storage.managed` read-only enterprise-policy) per access pattern, then generates tests for quota-exceeded behavior (`runtime.lastError` callback path + rejected promise async path), `storage.sync` per-item + total quotas, `storage.onChanged` event payload shape, multi-area write isolation, and Firefox-Chrome divergences (Firefox `storage.sync` quotas align with Chrome per MDN; Firefox `storage.managed` available; Firefox `storage.session` MV3-only). Output: a per-extension storage test file + matrix asserting the right area was chosen. Use when an extension persists state across sessions or devices and no test proves the chosen storage area survives its quota limits.

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.

playwright-extension-fixtures

Author the lower-level Playwright fixture pattern that 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. This is the launch-and-load layer shared by every extension test, not the assertions run on top of it. Use when authoring or debugging the fixture a Chromium extension test imports - popup, content script, service worker, options page, or side panel.

web-ext-cli-mozilla

Author, lint, run, build, and sign a Firefox / Chromium WebExtension using Mozilla's `web-ext` CLI v8. Covers `web-ext lint` (addons-linter wrapper, JSON output for CI), `web-ext run` (temporary install in firefox-desktop / firefox-android / chromium targets with hot-reload), `web-ext build` (deterministic zip), and `web-ext sign` (AMO submission API, listed vs unlisted channels, JWT credentials). Use 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.