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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill extension-storage-test-authorextension-storage-test-author
Overview
chrome.storage has four areas with non-overlapping quotas, persistence semantics, and enterprise-policy posture. Picking the wrong one is a class of bug the type checker can't catch: storage.sync silently rejects writes past its total or per-item limit, storage.session evaporates on browser restart, and storage.managed is read-only by definition and throws on write.
This skill emits the test suite that proves the right area was chosen, the quota gates fire on the documented thresholds, and the storage.onChanged payload shape matches across all writing code paths. The output is a Vitest / Playwright spec the extension ships in its test suite. Quota constants are stated once in the Step 2 decision matrix; the deeper worked templates, the version-specific floors, and the Firefox-Chrome divergence table live in references/storage-test-templates.md.
Composes with:
For Playwright-driven MV3 popup / content-script fixtures see browser-extension-tests. That skill covers chrome.storage usage assertions; this builder covers suite design - area selection, quota gates, event-payload conformance, multi-area isolation.
When to use
Workflow
Step 1 - Inventory the access pattern
For each storage call in the extension, capture these facts:
| Fact | What to record |
|---|---|
| Size | Worst-case bytes per write + total across keys |
| Cross-device | Must the value follow the user across browsers? |
| Lifetime | Must the value survive browser restart? Extension reload? |
| Trust | Is the writer the extension, or an external authority (enterprise policy)? |
grep -rn 'chrome\.storage\.\|browser\.storage\.' \
--include='*.{ts,js,tsx,jsx}' src/ \
> storage-access-inventory.txtStep 2 - Pick the area per the decision matrix
The facts from Step 1 drive area choice. All constants below are from cr-storage (opens in new window); state them once here and reference them by name later.
| Area | QUOTA_BYTES | Per-item | Lifetime | Cross-device | Writer |
|---|---|---|---|---|---|
storage.local | 10,485,760 (10 MB) | none documented | until extension removal | no | extension |
storage.sync | 102,400 (~100 KB) | 8,192 (8 KB) | persistent, synced | yes (when user signed in) | extension |
storage.session | 10,485,760 (10 MB) | none documented | cleared on disable, reload, update, or browser restart; MV3-only | no | extension |
storage.managed | - | - | as long as policy is in effect | varies | admin only - read-only for the extension |
storage.sync throughput caps: MAX_ITEMS = 512, MAX_WRITE_OPERATIONS_PER_MINUTE = 120, MAX_WRITE_OPERATIONS_PER_HOUR = 1,800. MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE is deprecated ("no longer has a sustained write operation quota") - drop any assertion referencing it. The version-specific floors (local 5 MB in Chrome 113-, session 1 MB in Chrome 111-) are in references/storage-test-templates.md; pin tests to the live constant, not a hard-coded number.
Emit a decision-matrix test:
import manifest from '../dist/manifest.json';
describe('storage area selection', () => {
it('uses storage.sync only for user-preference-sized data', () => {
// grep'd inventory; verify the sync writes are all < 8 KB
const syncCalls = readStorageInventory().filter(c => c.area === 'sync');
for (const c of syncCalls) {
expect(c.maxBytes).toBeLessThan(8 * 1024); // QUOTA_BYTES_PER_ITEM
}
});
it('does not use storage.session for data needed across browser restart', () => {
const sessionCalls = readStorageInventory().filter(c => c.area === 'session');
for (const c of sessionCalls) {
expect(c.requiresPersistence).toBe(false);
}
});
});Step 3 - Quota-exceeded behavior (core template)
Quota-exceeded writes fail immediately and set runtime.lastError (callback form) or reject the Promise (async form); tests must drive both paths. This per-item template is the runnable core - the total-quota, callback, MAX_ITEMS, event, isolation, and managed templates follow the same shape in references/storage-test-templates.md.
test('storage.sync rejects on per-item quota exceeded', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
const oversized = 'x'.repeat(9 * 1024); // 9 KB > 8 KB limit
try {
await chrome.storage.sync.set({ big: oversized });
return { ok: true };
} catch (e: any) {
return { ok: false, message: e.message };
}
});
expect(result.ok).toBe(false);
// exact message is unstable; assert quota-shaped text
expect(result.message).toMatch(/quota|QUOTA_BYTES/i);
});Step 4 - Author the remaining cells
One spec per row, from the templates in references/storage-test-templates.md:
| Test | Asserts |
|---|---|
storage.sync total-quota (>100 KB) | a write past QUOTA_BYTES = 102,400 rejects |
| callback-path equivalence | quota also sets runtime.lastError in callback form |
MAX_ITEMS (512) | the 513th unique key rejects |
storage.onChanged shape | listener receives (changes, areaName) with changes[key] = { oldValue?, newValue? } |
| multi-area isolation | a storage.local write is invisible to storage.sync |
storage.managed read-only | any managed.set rejects ("modify this namespace results in an error" per mdn-storage (opens in new window)) |
| Firefox parity | gate session / sync sign-in / managed per the divergence table |
Step 5 - Emit the suite
Write tests/storage.spec.ts covering every cell, paired with a YAML manifest mapping each spec to the matrix cell it covers:
# tests/storage-coverage.yaml
matrix:
area_selection: ["uses sync only for pref-sized data", "no session for cross-restart data"]
quota_exceeded: ["sync per-item >8 KB", "sync total >100 KB", "callback sets lastError"]
throughput: ["MAX_ITEMS = 512"]
events: ["onChanged shape (changes, areaName)"]
isolation: ["local invisible to sync"]
managed: ["managed write rejects"]
firefox_parity: ["storage.session MV3-only on Firefox"]CI gates on every cell having at least one passing test.
Worked example: a 4-row suite for a settings extension
For an extension that stores { theme: 'dark', apiKey: '...', tabsOpenCount: N }:
| Key | Area chosen | Reason | Quota test |
|---|---|---|---|
theme | storage.sync | User preference; cross-device | 8 KB per-item cap test |
apiKey | storage.local | Sensitive - should not leave device; gate on first-party-only | total-quota test |
tabsOpenCount | storage.session | Resets each session | session-clears-on-restart test |
| (admin policy URL) | storage.managed | Enterprise-only | managed-write-rejects test |
Each row produces one spec from the Step 3 core template and its references/storage-test-templates.md variants. Per mdn-storage (opens in new window), no area encrypts at rest ("Storage area is not encrypted, and shouldn't store confidential information") - if the extension stores credentials, add a separate encryption-at-rest test (out of scope here).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Test only happy-path set + get | Quota silently drops past 8 KB / 100 KB | Author quota tests per Step 3 |
| Assert exact error message string | Wording isn't pinned; matching /quota/i is the stable surface | Use regex match per Step 3 |
Use storage.sync for binary / image data | 100 KB total + 8 KB per-item caps; not designed for blobs | Move to storage.local per Step 2 |
Use storage.session for cross-restart data | Cleared on restart; tests pass in single session, prod fails on cold start | Step 2 decision matrix |
Skip storage.managed read-only test | Extension's own write code may silently throw in enterprise deployments | Test per Step 4 |
Assume MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE still applies | Deprecated per cr-storage (opens in new window) | Drop the constant from tests |
Hard-code the storage.local limit | Chrome 113- was 5 MB; current is 10 MB - pin the test to the live constant | Read the constant, not a literal |
Listen for onChanged once and expect no further fires | Every write fires; listener must filter or accumulate | Always filter on areaName + key |
Limitations
References
Storage test templates - extension-storage-test-author
View source (opens in new window)Storage test templates - extension-storage-test-author
Full worked templates for the cells summarized in Step 4 of the skill. Each runs from a playwright-extension-fixtures service-worker context; the core per-item template lives inline in the skill's Step 3.
Version-specific quota floors
Pin tests to the live constant, never a hard-coded number:
storage.sync total-quota (102,400 bytes)
test('storage.sync rejects past total-quota (~100 KB)', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
// 13 items x 8 KB = 104 KB > 102.4 KB total per QUOTA_BYTES
const batch: Record<string, string> = {};
const chunk = 'x'.repeat(7.9 * 1024); // just under per-item cap
for (let i = 0; i < 13; i++) batch[`k${i}`] = chunk;
try {
await chrome.storage.sync.set(batch);
return { ok: true };
} catch (e: any) {
return { ok: false, message: e.message };
}
});
expect(result.ok).toBe(false);
});Callback-path equivalence
Both forms must observe quota - the callback path sets runtime.lastError:
test('storage.sync callback path also sets runtime.lastError on quota', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const err = await sw.evaluate(() => new Promise<string | null>(resolve => {
const oversized = 'x'.repeat(9 * 1024);
chrome.storage.sync.set({ big: oversized }, () => {
resolve(chrome.runtime.lastError?.message ?? null);
});
}));
expect(err).not.toBeNull();
expect(err).toMatch(/quota|QUOTA_BYTES/i);
});MAX_ITEMS (512)
Write 513 unique keys, assert the 513th fails:
test('storage.sync rejects past MAX_ITEMS (512)', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
for (let i = 0; i < 512; i++) {
await chrome.storage.sync.set({ [`k${i}`]: '1' });
}
try {
await chrome.storage.sync.set({ k512: '1' });
return { ok: true };
} catch (e: any) {
return { ok: false };
}
});
expect(result.ok).toBe(false);
});Per-minute throughput (120 writes/min) is hard to drive deterministically; assert the extension's own write-batching stays under the cap instead.
storage.onChanged event-payload shape
Signature: chrome.storage.onChanged.addListener((changes, areaName) => void) with changes[key] = { oldValue?, newValue? }. Firefox receives the same shape per mdn-storage (opens in new window), so the test runs cross-browser unmodified:
test('storage.onChanged fires with correct shape on local set', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const event = await sw.evaluate(() => new Promise<any>(resolve => {
chrome.storage.onChanged.addListener(function listener(changes, area) {
chrome.storage.onChanged.removeListener(listener);
resolve({ changes, area });
});
chrome.storage.local.set({ theme: 'dark' });
}));
expect(event.area).toBe('local');
expect(event.changes.theme.newValue).toBe('dark');
expect(event.changes.theme.oldValue).toBeUndefined(); // first write
});Multi-area isolation
A write to one area must not appear in another:
test('storage.local writes are invisible to storage.sync', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
await chrome.storage.local.set({ isolated: 'localValue' });
const { isolated } = await chrome.storage.sync.get('isolated');
return isolated;
});
expect(result).toBeUndefined();
});storage.managed read-only enforcement
storage.managed is read-only; any write rejects ("Trying to modify this namespace results in an error" per mdn-storage (opens in new window)):
test('storage.managed rejects writes', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
try {
await (chrome.storage as any).managed.set({ foo: 'bar' });
return { ok: true };
} catch (e: any) {
return { ok: false, message: e.message };
}
});
expect(result.ok).toBe(false);
});Reading storage.managed requires a deployed enterprise policy; in CI, inject an extensions.managedStorage policy via the OS mechanism (Windows registry / macOS plist / Linux JSON) - out of scope here, but the assertion shape is:
const policy = await chrome.storage.managed.get('apiBaseUrl');
expect(policy.apiBaseUrl).toBe('https://policy-injected-url/');Firefox-Chrome divergences
| Concern | Chrome | Firefox | Test action |
|---|---|---|---|
storage.sync quotas | 102,400 / 8,192 / 512 / 1,800 per hour per cr-storage (opens in new window) | Per mdn-storage (opens in new window), MDN does not enumerate quota numbers in the high-level page; align tests to the StorageArea sub-page values and verify on Firefox stable | |
storage.session MV3-only | Yes per cr-storage (opens in new window) (Chrome 102+ MV3+) | Yes per mdn-storage (opens in new window) (MV3-only on Firefox) | Skip session tests when targeting MV2 |
storage.managed availability | Available; admin-configured per OS | Available per mdn-storage (opens in new window) | Cross-browser test ok; policy injection mechanism differs per OS |
| Sync sign-in | Chrome account required | Firefox account required | Skip sync round-trip tests on machines without sign-in; assert local-fallback behavior instead |
Firefox's storage.local "persists even when users clear browsing history/data (unlike localStorage)" per mdn-storage (opens in new window) - useful for a clear-history regression test.
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.
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.
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.
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.