Snapshot Testing the Generated Manifest

Guard an extension's generated manifest.json per browser with snapshot and rule-based tests: catching accidental permission increases, version and key drift, test-only entries leaking into production, schema validation, and reviewing diffs.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A dependency upgrade to the build plugin adds "tabs" to the generated manifest. Nobody notices until the release: Chrome disables the extension for every user until they accept a new permission warning, and many never do. In another release, a test-only http://localhost/* host permission leaks into production. When the manifest is generated — by WXT, Plasmo, CRXJS or your own script from a template with per-browser overrides — its final content is a build output that nobody reads. Snapshot tests make every change to it visible in code review, and a few rule-based assertions turn the most dangerous changes into hard failures. This guide sets both up. It belongs to unit and integration testing.

Why the manifest deserves its own tests

The manifest controls what the extension may do and what users are asked to approve. Changes with outsized consequences include new permissions or host permissions (which trigger re-approval and disable the extension until accepted in Chrome), broader content script matches, changed key (which changes the extension ID in development), CSP relaxations, removed web_accessible_resources (which break features), and per-browser keys appearing in the wrong build. A snapshot test records the full generated manifest per browser and fails on any difference, forcing a deliberate update that shows up as a diff in review. Rule tests encode invariants — “production never contains localhost”, “permissions are a subset of this allowlist” — that should never change silently, snapshot or not.

Two layers of manifest protectionThe build generates a manifest per browser; snapshot tests compare each with a committed file and show any change as a reviewable diff; rule tests enforce invariants such as allowed permissions, no localhost in production, and version matching package.json.Template + overridesmanifest.config.tsBuildchrome, firefox, safaridist/*/manifest.jsongeneratedtestsSnapshot per browserdiff in PRRule testsallowlists, invariantsSchema checkvalid keys
Snapshots make change visible; rules make dangerous change impossible.

Step-by-step: manifest tests

1. Generate manifests in a testable function

 1// manifest.config.ts
 2import pkg from "./package.json" with { type: "json" };
 3type Target = "chrome" | "firefox" | "safari";
 4
 5export function buildManifest(target: Target, { test = false } = {}) {
 6  const m: Record<string, any> = {
 7    manifest_version: 3,
 8    name: "__MSG_extName__",
 9    version: pkg.version,
10    default_locale: "en",
11    action: { default_popup: "popup.html", default_title: "__MSG_actionTitle__" },
12    background: target === "firefox" ? { scripts: ["sw.js"], type: "module" } : { service_worker: "sw.js", type: "module" },
13    permissions: ["storage", "contextMenus", "alarms", "scripting", "activeTab"],
14    optional_permissions: ["downloads", "notifications"],
15    host_permissions: ["https://api.readable.example/*"],
16    content_scripts: [{ matches: ["https://*/*"], js: ["content.js"], run_at: "document_idle" }],
17  };
18  if (target === "firefox") m.browser_specific_settings = { gecko: { id: "readable@example.com", strict_min_version: "115.0" } };
19  if (test) {
20    m.host_permissions.push("http://localhost/*");
21    m.content_scripts[0].matches.push("http://localhost/*");
22  }
23  return m;
24}

Execution context: the build configuration. A pure function from target and mode to a manifest object can be called directly in tests without running the bundler. If you use WXT or Plasmo, test the generated file in dist/ instead (step 2’s alternative). See browser-specific settings for Firefox and Safari.

2. Snapshot each production manifest

 1// tests/manifest.snapshot.test.ts
 2import { buildManifest } from "../manifest.config";
 3import { expect, test } from "vitest";
 4
 5for (const target of ["chrome", "firefox", "safari"] as const) {
 6  test(`${target} manifest`, async () => {
 7    const m = buildManifest(target);
 8    delete m.version;                                                 // versions change every release; tested separately
 9    await expect(JSON.stringify(m, null, 2)).toMatchFileSnapshot(`./__snapshots__/manifest.${target}.json`);
10  });
11}

Execution context: Vitest. File snapshots write readable JSON files next to the tests, which reviewers can open like any source file; a change to the manifest appears as a diff in the pull request. Remove fields that legitimately change every release (version) so snapshots change only when something meaningful does. To test bundler-generated output instead, read dist/<target>/manifest.json after npm run build.

Manifest changes and how tests treat themKinds of manifest changes — new permission, new host permission, broader matches, test entries in production, CSP change, version mismatch, new web accessible resource — and whether the snapshot shows it, a rule blocks it, or both.ChangeSnapshot diffRule testNew permissionYesFails unless allowlistedNew host permissionYesFails unless allowlistedlocalhost in productionYesAlways failsCSP relaxedYesFails on unsafe-evalVersion ≠ package.jsonExcludedFailsNew web-accessible resourceYesReview only
Every change is visible; the dangerous ones are blocked unless the allowlist is updated too.

3. Encode invariants as rule tests

 1// tests/manifest.rules.test.ts
 2const ALLOWED_PERMISSIONS = new Set(["storage", "contextMenus", "alarms", "scripting", "activeTab"]);
 3const ALLOWED_HOSTS = new Set(["https://api.readable.example/*"]);
 4
 5test.each(["chrome", "firefox", "safari"] as const)("%s production manifest invariants", (target) => {
 6  const m = buildManifest(target);
 7  for (const p of m.permissions) expect(ALLOWED_PERMISSIONS, `unexpected permission ${p}`).toContain(p);
 8  for (const h of m.host_permissions) expect(ALLOWED_HOSTS, `unexpected host ${h}`).toContain(h);
 9  const all = JSON.stringify(m);
10  expect(all).not.toMatch(/localhost|127\.0\.0\.1/);
11  expect(all).not.toMatch(/unsafe-eval|unsafe-inline/);
12  expect(m.version).toBe(pkg.version);
13  expect(m.manifest_version).toBe(3);
14});
15
16test("test build adds localhost only in test mode", () => {
17  expect(JSON.stringify(buildManifest("chrome", { test: true }))).toMatch(/localhost/);
18});

Execution context: Vitest. Rule tests fail even if someone updates the snapshot without thinking. Adding a permission requires editing the allowlist in the same pull request — a visible, reviewable decision with store-review and user-warning consequences. See reducing permission warnings at install.

A plugin upgrade tries to add a permissionA dependency update makes the build add tabs to permissions; the snapshot test shows the diff and the rule test fails because tabs is not allowlisted; the author removes the plugin option that added it, and both tests pass.Dependency updateTestsAuthormanifest gains "tabs"snapshot diff + unexpected permission tabs ✗disable plugin auto-permission✓
The permission never reaches users because the rule test blocks it.

4. Validate against a schema

1import Ajv from "ajv";
2import schema from "./schemas/chrome-manifest.schema.json" with { type: "json" };     // community JSON schema, pinned
3test("chrome manifest matches schema", () => {
4  const validate = new Ajv({ strict: false }).compile(schema);
5  expect(validate(buildManifest("chrome")), JSON.stringify(validate.errors)).toBe(true);
6});

Execution context: Vitest. Schema validation catches typos (content_script), wrong types and invalid values. Pin the schema in the repository so tests don’t change under you. For Firefox, web-ext lint does a thorough manifest check against the built output; see linting extensions with web-ext lint in CI.

5. Check referenced files exist

1test("every file referenced by the manifest exists in the build", () => {
2  const m = JSON.parse(readFileSync("dist/chrome/manifest.json", "utf8"));
3  const files = [m.background?.service_worker, m.action?.default_popup, ...m.content_scripts.flatMap((c) => [...(c.js ?? []), ...(c.css ?? [])]),
4    ...Object.values(m.icons ?? {}), ...(m.web_accessible_resources ?? []).flatMap((w) => w.resources.filter((r) => !r.includes("*")))];
5  for (const f of files.filter(Boolean)) expect(existsSync(`dist/chrome/${f}`), f).toBe(true);
6});

Execution context: Vitest after the build. A renamed entry point or a missing icon makes the extension fail to load or look broken; this catches it before anyone installs the build.

6. Review snapshot updates deliberately

Update snapshots with vitest -u only after reading the diff. In code review, treat changes to __snapshots__/manifest.*.json like changes to security configuration: who asked for this permission, what feature needs it, is it optional instead? A short note in the pull request description answering those questions makes review quick.

Common mistakes

  • Not testing a generated manifest. Plugins and templates change it silently.
  • Snapshots only. Easy to update without thinking; add rule tests.
  • Including the version in snapshots. Every release changes them.
  • Test-only entries in production. Generate manifests by mode and assert.
  • Not checking referenced files. Missing files break loading.

Cross-browser variation

  • Chrome / Edge: permission increases disable the extension until users approve; rule tests matter most here.
  • Firefox: separate snapshot with browser_specific_settings, background.scripts; host permissions are optional at install in MV3.
  • Safari: snapshot the manifest copied into the Xcode project; it may differ (no unsupported keys).

Verification

  1. Add a permission to the template and confirm both the snapshot and rule tests fail.
  2. Build in test mode and confirm localhost appears only there.
  3. Rename the popup file and confirm the referenced-files test fails.
  4. Confirm snapshot files are committed and reviewed like source.

FAQ

Inline or file snapshots?

File snapshots keep manifests readable and diffable as standalone JSON.

Do I need this if I hand-write the manifest?

Rule tests still help — they encode policy — but the snapshot adds less when the manifest is already source.

How do I handle optional permissions?

Snapshot them and add them to a separate allowlist; they don’t trigger install warnings but still need justification in review.

Should CI fail on any snapshot change?

CI fails until the snapshot is updated in the same pull request, so every change to the manifest is reviewed along with the code that caused it.

What about the Safari manifest inside Xcode?

Add a test that compares the manifest in the Xcode project’s resources folder with the generated Safari manifest, so a stale copy is caught before archiving.

Other Testing, Debugging & Performance Optimization Resources