building-browser-extensions
Skill111 starsChanged 18 days agoMITView on GitHub
Builds and tests Manifest V3 browser extensions — service-worker message channels that survive teardown, chrome.storage read-modify-write safety, settings validation in the popup, and a Jest chrome mock that can actually fail. Use when writing or reviewing a background service worker, a popup that reads/writes chrome.storage, an extension's Jest suite, or debugging an extension whose UI silently stops responding.
- Reads credentials
In this repository41 other files it gives its agents.
- building-accessible-interfacesSkill
- wiring-application-configSkill
- shipping-build-artifactsSkill
- permissioning-ci-workflowsSkill
- concurrent-branchesSkill
- shipping-across-surfacesSkill
- writing-defect-reportsSkill
- reporting-derived-metricsSkill
- guarding-destructive-operationsSkill
- running-github-actions-efficientlySkill
- keeping-git-repos-cleanSkill
- designing-interaction-statesSkill
- designing-marketing-pagesSkill
- rendering-untrusted-contentSkill
- reproducing-ci-locallySkill
- running-resumable-sync-jobsSkill
- normalizing-text-for-measurementSkill
- calibrating-thresholds-and-baselinesSkill
- designing-interfacesSkill
- verifying-external-behaviorSkill
- building-go-projectsSkill
- designing-python-apisSkill
- building-python-clisSkill
- improving-python-code-qualitySkill
- building-python-communitiesSkill
- documenting-python-librariesSkill
- deferring-heavy-dependenciesSkill
- reviewing-python-librariesSkill
- building-llm-backed-featuresSkill
- building-python-mcp-serversSkill
- packaging-python-librariesSkill
- optimizing-python-performanceSkill
- setting-up-python-librariesSkill
- managing-python-releasesSkill
- auditing-python-securitySkill
- testing-python-librariesSkill
- building-python-web-appsSkill
- building-rust-cratesSkill
- building-scala-projectsSkill
- building-swift-appsSkill
- shipping-swift-appsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 10 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈10
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 48
Treat a missing or falsy response as failure, not success — a torn-down worker resolves sendMessage with undefined.
- Distinctive: line 92
Defaults-based, never partial: Every key a consumer indexes into exists, even on a first write after storage was cleared.
- Distinctive: line 104
Every writer must use that route, including the popup's save handler.
- Distinctive: line 106
Test this with a controlled interleaving: hold A after its read, request B's save, then release A and await both acknowledgements.
- Distinctive: line 134
Use the bypass only for a manual action that explicitly requests the same reminder delivery.
- Distinctive: line 145
Test the distinction at the handler boundary: a manual reminder works off-hours.
- Distinctive: line 208
If storage changes while the review is open, re-reading it on Download silently exports something the user never reviewed.
- Distinctive: line 212
Test the serializer with extra runtime/history fields and assert the whole exported object, not just that preference keys are present.
- Distinctive: line 240
Test the actual popup summarizer rather than a second implementation of its date math.
- Very specific: line 253
Import spawnSync from node:child_process.
- Distinctive: line 258
Use distinct values for records just before midnight, exactly at midnight, within today, and later than the fixed current instant.
- Distinctive: line 261
Test midnight inclusion and future exclusion separately so an implementation that drops both cannot pass.
- Very specific: line 303
A mock that drops async responses: If the fake sendMessage invokes listeners in a forEach and resolves responses[0], then any listener that …
- Very specific: line 311
Synchronous mock storage cannot detect a missing
await: If the fake set mutates its backing object and returns an already-resolved …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Manifest V3 Extensions
An MV3 background script is not a long-lived page — it is a service worker the browser tears down whenever it looks idle. Almost every bug below is the same bug in different clothes: work that was started but never awaited, or state that was written from a stale snapshot. Both fail silently. There is no crash, no console error in front of the user — just a setting that reverts, a button that does nothing, or a suite that stays green over a broken handler.
Await every side effect before the handler resolves
A fire-and-forget chrome.storage.local.set can be killed with the worker. If a
message handler's caller depends on that write, the write must complete before
the response is sent.
MV3's onMessage closes the reply channel as soon as the listener returns unless
it returns literal true:
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.action === 'triggerAction') {
doWork() // resolves only after its writes land
.then(() => sendResponse({ success: true }))
.catch((error) => sendResponse({ success: false, error: String(error) }));
return true; // REQUIRED: keeps the channel open
}
});Then let the caller await the response instead of guessing at a delay:
// BAD — a race dressed up as a fix. Slow worker: stale read. Fast worker: wasted 500ms.
chrome.runtime.sendMessage({ action: 'triggerAction' });
setTimeout(loadFromStorage, 500);
// GOOD — the response is the signal that the write is durable.
const res = await chrome.runtime.sendMessage({ action: 'triggerAction' });
if (!res?.success) return showRetryState(res?.error);
await loadFromStorage();Treat a missing or falsy response as failure, not success — a torn-down worker
resolves sendMessage with undefined. The same rule covers any awaited
teardown work behind an acknowledgement: if the handler reschedules an alarm,
await the clear/create pair before responding, or the caller refreshes its
UI from state that has not been rebuilt yet.
??, never ||, for numeric settings
0 is a legitimate value for an hour, a count, or an offset, and || throws it
away. A user who sets a start hour of midnight gets the default instead.
const startHour = stored.startHour ?? DEFAULTS.startHour; // 0 surviveschrome.storage read-modify-write is a lost update
get → mutate → set on the whole settings object races every other context
(popup, worker, options page) writing the same key. Worse, if the object is
absent from storage, code that spreads a local variable persists a partial
settings record — and the next reader's stored || DEFAULTS keeps the truthy
partial, so a missing nested key throws.
Re-read inside the write path to avoid carrying an old selection-time snapshot, and rebuild over the defaults rather than over whatever snapshot you happen to be holding. This narrows the stale-read window; it does not make the update atomic:
async function persistLastIndex(category, index) {
const { settings } = await chrome.storage.local.get('settings'); // fresh read
await chrome.storage.local.set({
settings: {
...DEFAULTS,
...settings,
enabledCategories: { ...DEFAULTS.enabledCategories, ...settings?.enabledCategories },
lastIndices: { ...DEFAULTS.lastIndices, ...settings?.lastIndices, [category]: index },
},
});
}Two things that snippet is doing deliberately:
- Defaults-based, never partial. Every key a consumer indexes into exists, even on a first write after storage was cleared.
- Nested objects merged explicitly. A shallow
{...DEFAULTS}aliasesDEFAULTS.enabledCategories— it is the same object, typically a module global shared by worker and popup. Mutating the stored copy later reaches back and rewrites your defaults for the rest of the process.
A fresh read still races: worker A reads, popup B saves, then A writes its
whole object and overwrites B's change. Awaiting A's set proves completion,
not isolation. If both contexts can update the same key, route their writes
through one owner and serialize the whole read–merge–write sequence there;
serializing only set leaves both callers free to read the same old value.
Every writer must use that route, including the popup's save handler.
Test this with a controlled interleaving: hold A after its read, request B's save, then release A and await both acknowledgements. Assert the final settings contain both independent changes. A synchronous storage mock or a test that places B's save only before A's fresh read cannot expose the remaining race.
The cleanest version of this rule: make selection pure and persistence
explicit. A function that picks a random item should return
{ item, category, index } and write nothing; the caller awaits the persist.
Pure selection is trivially testable, and the write can no longer be silently
skipped.
Share reminder delivery only when the side effects match
A "show something now" routine usually serves both a scheduled alarm (which must respect quiet hours / weekends) and a manual button (which must not). Do not fork into a second near-copy; take an explicit opt-out and default it to the safe value:
async function showReminder(skipTimeCheck = false) {
if (!skipTimeCheck && !isWithinActiveHours(settings)) return;
// ...
}
// alarm handler: showReminder(); → gated
// manual reminder: showReminder(true); → schedule bypassedUse the bypass only for a manual action that explicitly requests the same reminder delivery. It bypasses the schedule, not every policy: pause and active session checks need their own deliberate handling.
A catalog's "select this item" action has different side effects. Reusing
showReminder(true) would send an unwanted notification and update random
repeat-avoidance history. Give selection its own handler: validate integer
category/item indices and bounds before reading session state, reject a valid
future session deadline, then await only the selected-item write. Exact session
expiry permits selection; use the deadline validation below.
Test the distinction at the handler boundary: a manual reminder works off-hours; a catalog selection writes the chosen item without notifying or changing repeat-avoidance history; invalid indices and an active session leave storage untouched. Sharing a pure lookup helper is useful; sharing an effectful pipeline is correct only when the requested effects match.
HTML input constraints are inert; validate in JS
min="0" max="23" on a number input is enforced only by form submission or an
explicit checkValidity(). A popup that reads .value on a click handler gets
none of it, and parseInt('') is NaN:
// BAD — NaN >= NaN is false, so this "range check" passes for a blank field.
const start = parseInt(startInput.value);
if (start >= end) return showError();NaN then serializes through chrome.storage to null, gets replaced by the
default on read (so the app looks fine), and renders as an empty field next
time the popup opens — sticky corruption behind a "Settings saved ✓" toast.
Out-of-range values are stored verbatim and can make an active-hours predicate
true for every hour of the day.
function readHour(input, name) {
const raw = input.value.trim();
if (!/^\d+$/.test(raw)) throw new ValidationError(name);
const n = Number(raw);
if (!Number.isInteger(n) || n < 0 || n > 23) throw new ValidationError(name);
return n;
}Validate the whole string before conversion: parseInt('9.5', 10) and
parseInt('9hours', 10) both return 9, which passes integer and range checks.
Number('') returns 0, so conversion alone also accepts a blank as midnight.
The digit check rejects those inputs while preserving a legitimate 0.
Validate in the shared save path, not per-field in the markup — and report the
failure instead of showing success. Test valid boundaries 0 and 23, and
reject blank, fractional, trailing-text, negative, and 24 inputs; every rejected
save must leave storage untouched and show an error.
Export only preferences, and download the reviewed snapshot
A preferences export is a separate data contract from chrome.storage. Build it
with a pure allowlist serializer; do not dump storage or spread the stored object
and delete today's known runtime keys. New session/history fields must stay out
of the export without requiring another exclusion.
function buildPreferencesExport(settings) {
return { workStartHour: settings.workStartHour,
workEndHour: settings.workEndHour };
}
// Prepare the review, then retain the exact serialized payload.
const reviewedJson = JSON.stringify(buildPreferencesExport(settings), null, 2);
preview.textContent = reviewedJson;
// The Download handler creates its Blob from reviewedJson, not a fresh read.If storage changes while the review is open, re-reading it on Download silently exports something the user never reviewed. Keep the reviewed string until the review is dismissed or explicitly regenerated.
Test the serializer with extra runtime/history fields and assert the whole
exported object, not just that preference keys are present. Change storage after
preparing the preview and assert the downloaded Blob's text equals the reviewed
string. In jsdom, provide URL.createObjectURL and spy on
HTMLAnchorElement.prototype.click to inspect the payload without relying on
unsupported navigation.
A stored deadline must be a usable Date, not just a number
Snooze/pause features persist an epoch-millisecond deadline and compare it to
Date.now(). Storage hands back whatever was written, so validate on read:
const MAX_DATE_MS = 8.64e15; // largest time a JS Date can represent
function readDeadline(raw) {
return Number.isSafeInteger(raw) && Math.abs(raw) <= MAX_DATE_MS ? raw : null;
}
const paused = (d) => d !== null && d > Date.now(); // strict: expiry instant is "not paused"A finite but oversized value (1e20) passes Number.isFinite, then
new Date(raw) is Invalid Date and the popup renders "Paused until Invalid Date"
forever. null/NaN/strings must read as "not paused", never as a deadline. Test
the exact-expiry boundary (deadline === now is expired) and an over-range value.
Calendar summaries need calendar-boundary tests
A local activity calendar groups by local day, not by elapsed 24-hour buckets. Test the actual popup summarizer rather than a second implementation of its date math. Launch it in a Node child process with an explicit timezone so the test does not inherit the developer's or CI runner's zone:
const result = spawnSync(process.execPath, ['tests/calendar-probe.cjs'], {
env: { ...process.env, TZ: 'America/New_York' },
encoding: 'utf8',
});
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toEqual(expectedDailyTotals);Import spawnSync from node:child_process. The probe loads the production
summarizer and emits its result as JSON; keep assertions in the parent so a probe
failure cannot look like an empty successful summary. Fix the summarizer's notion
of "now" inside that process as well; setting TZ does not freeze time.
Use distinct values for records just before midnight, exactly at midnight, within today, and later than the fixed current instant. Assert the entire daily result: a matching grand total can hide a record assigned to the wrong day. Test midnight inclusion and future exclusion separately so an implementation that drops both cannot pass. Include a spring-forward date such as 2025-03-09 in that zone, whose local day is 23 hours; a UTC-only fixture cannot expose subtracting a fixed 24 hours to find the previous local midnight.
A throw during DOMContentLoaded kills the whole popup
Popup init typically loads state and binds listeners in one handler. If the
state load throws — say a partial settings object missing a nested key — the
handler unwinds before addEventListener runs, and every button in the popup is
dead with no visible error. Bind listeners first, then load state inside
try/catch and degrade to defaults.
Notifications: stable ids replace, and openPopup can reject
Reusing one notification id means a new notification replaces the pending one —
usually what you want, so state it deliberately rather than discovering it. In a
click handler, clear the clicked id before awaiting chrome.action.openPopup(),
and catch that call: it rejects when there is no active window to attach to.
Action buttons (buttons on a basic notification) are not rendered on every
OS/Chrome combination. Treat them as a shortcut, never the only route: the body
click must still reach every action, and a button handler such as snooze must
re-check pause/session state itself when it reuses a code path that skips it.
Your chrome mock is part of the system under test
A hand-rolled chrome mock decides which bugs are reachable. Four failure modes,
all of which produce a green suite over broken code:
Stubs leak across tests. jest.clearAllMocks() clears recorded calls but
keeps implementations, so one test's sendMessage.mockResolvedValue(...)
answers for every later test in the file — silently unhooking the real listener.
Have the reset helper reinstall default implementations:
function resetAll() {
jest.clearAllMocks();
installDefaultImplementations(chrome); // not just clearAllMocks()
}A mock that drops async responses. If the fake sendMessage invokes
listeners in a forEach and resolves responses[0], then any listener that
returns true and responds later resolves to undefined — so every handler
using the async pattern above appears to fail. Honor return true by resolving
the promise the listener eventually fulfills. Until it does, a test needing a
real round trip must invoke the listener directly
(chrome.runtime.onMessage.addListener.mock.calls[0][0]).
Synchronous mock storage cannot detect a missing await. If the fake set
mutates its backing object and returns an already-resolved promise, the write
wins the microtask race whether or not the handler awaited it. To test ordering
you must gate the write, then prove the handler is still pending:
let release;
const gate = new Promise((r) => { release = r; });
const realSet = chrome.storage.local.set.getMockImplementation();
chrome.storage.local.set.mockImplementation((items) =>
items.settings ? gate.then(() => realSet(items)) : realSet(items));
const pending = handler({ action: 'save' }, {}, sendResponse);
for (let i = 0; i < 50; i++) await Promise.resolve(); // drain microtasks
expect(sendResponse).not.toHaveBeenCalled(); // fails if the await is missing
release();
await pending;
expect(sendResponse).toHaveBeenCalledWith({ success: true });getMockImplementation() returns the default the reset helper installed, so this
composes with the mock instead of replacing storage behavior.
For a multi-step side effect, gate the last promise the acknowledgement claims
is complete, not merely an earlier step. Gating alarms.clear does not prove
that alarms.create is awaited: this broken implementation still passes a
clear-gated test while replying before the new alarm exists.
async function setupAlarm() {
await chrome.alarms.clear('reminder');
chrome.alarms.create('reminder', schedule); // BUG — floating promise
}Gate create itself and assert both the response and the externally visible
effect remain pending until release:
let releaseCreate;
const createGate = new Promise((r) => { releaseCreate = r; });
chrome.alarms.create.mockImplementation(() => createGate);
listener({ action: 'updateSettings' }, {}, sendResponse);
for (let i = 0; i < 50; i++) await Promise.resolve();
expect(sendResponse).not.toHaveBeenCalled();
releaseCreate();
for (let i = 0; i < 50; i++) await Promise.resolve();
expect(sendResponse).toHaveBeenCalledWith({ success: true });Apply the same rule to notification creation, storage writes, and any helper whose name implies that a sequence has finished: the helper must await every nested promise before its caller can truthfully acknowledge success.
Fake timers are dropped the moment you go real. jest.useRealTimers()
mid-test discards setSystemTime, so any new Date() afterward sees wall-clock
time and a time-of-day branch follows whatever hour CI runs at — green by day,
red at night. Stay on fake timers and drain microtasks in a loop instead of
awaiting a real setTimeout.
Event helpers fire and forget. A mock's onAlarm._trigger(name) (or any
onX._trigger) calls listeners synchronously and discards the promises they
return, so an assertion on the line after it races the handler's async work. For
deterministic async assertions, take the registered listener and await it
directly:
const listener = chrome.alarms.onAlarm.addListener.mock.calls[0][0];
await listener({ name: 'reminder' }); // resolves only when the handler does
expect(chrome.notifications.create).toHaveBeenCalledTimes(1);Under jest.useFakeTimers() setImmediate is faked too, so
await new Promise(setImmediate) hangs; use the microtask-drain loop above.
Two smaller traps: throwing from chrome.notifications.create is the cheapest way
to drive a handler's rejection path, but if the handler ever stops awaiting or
catching it, Jest dies with an unhandled rejection and worker crashes rather than
a named assertion failure — still red, just unreadable. And a tests/setup.js
that replaces global.console swallows probe output even under --verbose; write
to a temp file when you need to see inside a run.
Smoke-testing the unpacked popup in a real browser
Jest proves logic; a browser pass proves the popup renders and binds. Load the
unpacked extension in a persistent-context browser driver and open
chrome-extension://<id>/popup.html. Three traps:
- Do not use string predicates such as
page.wait_for_function("..."): they are evaluated withevaland violate the extension's CSP. Use locator assertions, or polllocator.inner_text()from the host script. Never relax the extension CSP to make verification pass. - Entrance animations wash out screenshots. A tab switch re-triggers a fade-in; wait for it to finish (or disable animations) before a full-page capture, and never pixel-diff regions that show a live clock or countdown.
- Wait on the observable, not on a storage value that may not change. With one eligible item, a "pick another" action writes the same value back, so wait for the button label to return to idle instead.
- A step that only records is not a guard. A smoke step that logs "what happened" (a reminder replaced the running exercise, the open popup still shows the old count) and then prints PASS keeps passing after the bug is fixed and after it regresses. Every PASS label must come from an assertion on the fixed behavior; keep genuinely exploratory observations under a different label that cannot turn the run green. Also keep asserted message text in one place shared with the UI: when validation copy changes (e.g. "hour" becomes "time"), a harness holding a literal copy fails with the old string and gets dismissed as noise. Never read a harness exit status as release sign-off until each step's assertion is known to fail when its fix is reverted.
PR-comment CI steps need two permissions
A workflow step that comments on a pull request via the issues API needs both
issues: write and pull-requests: write, and such a step is usually gated
if: github.event.action == 'opened' — so pushing another commit re-runs the
workflow as synchronize, skips the step, and turns the job green without
fixing anything. See
permissioning-ci-workflows for the
scope rules and how to re-verify an event-conditioned step.
Checklist
MV3 extension health:
- [ ] every onMessage handler that responds asynchronously returns true and replies on both paths
- [ ] callers await the response; no setTimeout used to "wait for" a write
- [ ] a missing/falsy sendMessage response is treated as failure
- [ ] numeric settings read with ?? (0 is valid), never ||
- [ ] storage writes re-read first and rebuild over DEFAULTS; nested objects merged explicitly
- [ ] selection/computation is pure; persistence is a separate awaited call
- [ ] manual reminder delivery bypasses only the intended schedule gate; alarms keep the default
- [ ] catalog selection validates indices and session state, without notification or repeat-history effects
- [ ] settings validated in JS (Number.isInteger + range), not by inert HTML min/max
- [ ] stored deadlines validated as safe integers within Date range; expiry uses strict `>`
- [ ] popup binds listeners before loading state; state load degrades to defaults
- [ ] chrome mock: reset reinstalls default implementations; async responses honored
- [ ] at least one test gates a write to prove the handler awaits it
- [ ] multi-step effects are tested by gating the terminal promise, not only an earlier step
- [ ] fake timers never swapped for real timers mid-test
- [ ] notification buttons are optional shortcuts; the body click reaches every action
- [ ] popup smoke test uses locator waits (no string predicates) and settled animations
- [ ] PR-comment workflow step has issues: write AND pull-requests: writeDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

