CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

storage-test-templates.mdreferences/

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.local QUOTA_BYTES: 10 MB now; 5 MB in Chrome 113 and earlier per cr-storage.
  • storage.session QUOTA_BYTES: 10 MB now; 1 MB in Chrome 111 and earlier per cr-storage.
  • MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE: deprecated - "The storage.sync API no longer has a sustained write operation quota." Remove any assertion on it.

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, 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):

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

ConcernChromeFirefoxTest action
storage.sync quotas102,400 / 8,192 / 512 / 1,800 per hour per cr-storagePer mdn-storage, 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-onlyYes per cr-storage (Chrome 102+ MV3+)Yes per mdn-storage (MV3-only on Firefox)Skip session tests when targeting MV2
storage.managed availabilityAvailable; admin-configured per OSAvailable per mdn-storageCross-browser test ok; policy injection mechanism differs per OS
Sync sign-inChrome account requiredFirefox account requiredSkip 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 - useful for a clear-history regression test.

SKILL.md

tile.json