08 — Resilience & Test Strategy¶
Research doc. How to keep our YouTube-audio extension alive against a moving target (YouTube changes weekly) and how to verify it deterministically. Grounded in the real, cloned source of uBlock Origin and SponsorBlock.
Evidence base (cloned & read directly, July 2026):
| Repo | Path used for citations | Commit | Date |
|---|---|---|---|
| gorhill/uBlock | uBlock/… | 697b2f1 | 2026-07-10 |
| ajayyy/SponsorBlock | SponsorBlock/… | 4a118fb | 2026-07-01 |
| ajayyy/maze-utils (SB submodule, pinned = cloned) | maze-utils/… | 6b1ba69 | 2026-06-09 |
Citations are repo/path/file:line from those commits. The maze-utils clone SHA exactly matches the commit SponsorBlock pins as a submodule (git ls-tree HEAD maze-utils → 6b1ba69…), so every maze-utils line number is the one SponsorBlock actually ships.
1. Executive summary¶
Two independent problems, two independent answers.
(A) Stay alive against YouTube changes. The single highest-leverage resilience feature is a remote, declarative "rescue" config that the signed extension pulls at runtime — the uBlock Origin model. uBO fixes breakage for hundreds of millions of users without shipping an extension update by fetching filter/scriptlet lists from a CDN, caching them, and reloading them live (uBlock/src/js/assets.js). We can do the same with a tiny JSON file (URL matchers, DOM-selector tables, feature kill-switches, and identifiers that reference behaviours already shipped in the signed package — never code). This is legal on AMO (data is allowed; remote code is not) and compatible with the ghost/no-telemetry constraint (a static, credential-less GET with no user-identifying data). The second pillar is client-side feature detection + fail-open: SponsorBlock never hard-codes a single selector — it tries an ordered list of fallbacks, waits for elements via MutationObserver, and re-attaches when YouTube swaps the DOM (maze-utils/src/dom.ts, maze-utils/src/video.ts). Our non-negotiable invariant: if a hook is missing, do nothing to YouTube — worst case is "audio-only silently stops working, video still plays," never "YouTube is broken."
(B) Verify deterministically. Adopt a 5-layer ladder where layers 1–4 are deterministic and CI-gating and layer 5 (the existing live Selenium harness) is a non-gating nightly canary that is allowed to be flaky:
- Unit over the real
js/source in jsdom (fix the unsound tests first). - Integration — background↔content messaging wired together in jsdom with the existing
chromemock. - Fixture replay — recorded
ytInitialPlayerResponseJSON + a frozen watch-page DOM skeleton, so page-context hooks run without live YouTube. - Packaged-extension E2E on real Firefox against a local fake-YouTube page — proves the actual signed
.xpi(manifest, permissions, content-script injection,webRequest) works in a real browser, deterministically. This is the layer we are missing today. - Live smoke (
tests/e2e/verify-firefox.mjs) — real Firefox + real YouTube; breakage detection, not gating.
Both uBO and SponsorBlock validate this shape: their deterministic CI runs unit/fixture tests that import real modules (uBO: createWorld('./index.js') in uBlock/platform/npm/tests/snfe.js; SB: import { exportTimes } from "../src/utils/exporter" in SponsorBlock/test/exporter.test.ts), while their live-browser Selenium test is disabled by default (SponsorBlock/test/selenium.test.ts:6 uses xtest). Our current jest tests do the opposite of the proven pattern — they re-declare copies of the functions inside beforeEach and never import js/, so js/ coverage is 0% and green never proves the shipped code works.
2. Resilience patterns from uBO & SponsorBlock¶
2.1 uBO: remote-updatable resources (the hotfix-without-release engine)¶
uBO's asset system is exactly the capability we want: ship fixes as data, apply them at runtime.
Asset manifest — every list has origin + CDN mirrors + diff path. uBlock/assets/assets.json:1 maps an asset key to content ("internal" vs "filters"), an updateAfter interval in days, a contentURL[] (canonical origin plus a local in-extension fallback path), a cdnURLs[] (multiple independent mirrors), and a patchURLs[] (diff base). Example (uBlock/assets/assets.json:37, the ublock-filters list):
"ublock-filters": {
"content": "filters",
"contentURL": [ "https://ublockorigin.github.io/uAssets/filters/filters.txt",
"assets/ublock/filters.min.txt", "assets/ublock/filters.txt" ],
"cdnURLs": [ "https://ublockorigin.github.io/uAssetsCDN/filters/filters.min.txt",
"https://ublockorigin.pages.dev/filters/filters.min.txt",
"https://cdn.jsdelivr.net/gh/uBlockOrigin/uAssetsCDN@main/filters/filters.min.txt" ],
"patchURLs": [ "https://ublockorigin.github.io/uAssetsCDN/filters/",
"https://ublockorigin.pages.dev/filters/" ]
}
The mirrors are GitHub Pages, Cloudflare pages.dev, and jsDelivr — three independent, free static hosts. A solo dev can host all three today.
URL selection spreads load and prefers cache/origin as configured. getContentURLs() (uBlock/src/js/assets.js:181) assembles the candidate URL list, honours favorLocal/favorOrigin, and randomly shuffles the CDN list (uBlock/src/js/assets.js:200-205) so users don't hammer one mirror.
Fetch is cache-then-network with defensive guards. assets.fetchText() (uBlock/src/js/assets.js:330) adds a cache-bypass token for external URLs (modulo a prime so it doesn't collide across days, :346-350) and — critically — rejects anything that looks like HTML (if ( text.startsWith('<') && text.endsWith('>') ) → error, uBlock/src/js/assets.js:371-375), so a captive-portal or error page is never mistaken for a filter list. getRemote() (uBlock/src/js/assets.js:1003) iterates the candidate URLs, and on success writes the fetched content to a runtime cache (assetCacheWrite, :1050) and extracts metadata directives from the list body (Last-Modified, Expires, Diff-Path, Diff-Expires, :1056-1062). On failure it records the error and moves to the next URL (:1071-1073). A fetched copy that is older than the cached one is ignored (resourceIsStale, :1044-1046).
Applied at runtime, no extension update. The fetched list is stored in cacheStorage via assetCacheWrite() (uBlock/src/js/assets.js:748); the updater then broadcasts { what: 'assetUpdated' } (uBlock/src/js/assets.js:1318), an after-asset-updated observer recompiles the affected list and invalidates the compiled-engine "selfie" snapshot (µb.selfieManager.destroy() + this.loadFilterLists(), uBlock/src/js/storage.js:661, :713) — the new rules take effect at runtime without the user updating the extension.
Differential (diff) updates — fixes as tiny patches. Since uBO 1.54.0, lists carrying Diff-Path/Diff-Expires are updated by downloading only a patch, not the whole list. diffUpdater() (uBlock/src/js/assets.js:1230) gathers candidates, spawns a dedicated Worker('js/diff-updater.js') (:1269), posts each asset's diff details, and on status === 'updated' writes the patched text back to cache (:1298-1311); statuses nopatch-yet/nodiff skip cleanly (:1315-1317). The worker itself parses and applies the patch: parsePatch() / applyPatch() (Perl-_patch-style, uBlock/src/js/diff-updater.js:68, :107), applyPatchAndValidate() (:156), and fetchPatchDetailsFromCDNs() which resolves the patch file against patchURLs/cdnURLs (uBlock/src/js/diff-updater.js:183-207). If patching fails, uBO falls back to a full fetch. (Confirmed by Mozilla-hosted uBO docs and the v1.54 announcement — see references.)
Network-supplied patches are integrity-checked before use. applyPatchAndValidate() (uBlock/src/js/diff-updater.js:156-181) applies the diff, then computes the SHA-1 of the patched text and rejects it unless it matches the checksum shipped in the patch (sha1Full.startsWith(checksum) === false → badchecksum); it also feature-detects crypto.subtle and bails (nocrypto) rather than throwing. So a corrupted/tampered patch is discarded and the list is left untouched until the next full fetch. This is the concrete precedent for signing/validating our rescue config (§2.3).
Update scheduling is throttled and interval-driven. Inter-fetch delay defaults to updaterAssetDelayDefault = 120000 ms (2 min, uBlock/src/js/assets.js:1184). updateNext() skips assets whose writeTime + updateDelay > now (:1399-1400), where the delay comes from getUpdateAfterTime() (:125) driven by the list's Expires: directive (uBlock/src/js/assets.js:55-64 parses Expires: N days|hours) or the manifest updateAfter (:137-138); the built-in default is 5 days. The cycle is scheduled through browser.alarms so it survives MV3 service-worker suspension (µb.scheduleAssetUpdater sets both a vAPI.defer timer and a persistent alarm, uBlock/src/js/storage.js:1584-1587); default cadence knobs live at uBlock/src/js/background.js:48-50 (autoUpdateAssetFetchPeriod: 5, autoUpdateDelayAfterLaunch: 37, autoUpdatePeriod: 1), with an emergency fast-path when a list is badly stale (uBlock/src/js/start.js:507-526). Even the manifest of sources is remote-updatable (assets.json has updateAfter: 13; a refreshed manifest is reloaded in place, assets.js:1429-1431).
Safety model — remote lists cannot ship code (this is the key precedent for us). A remote filter list is declarative text. It can invoke a scriptlet by name (a token + string args), but the scriptlet code ships inside the signed extension (uBlock/src/js/resources/scriptlets.js, uBlock/src/js/scriptlets/). The parser resolves the token through a scriptletDB (uBlock/src/js/scriptlet-filtering-core.js:53, store at :186), and a filter referencing an unknown/renamed scriptlet silently no-ops (redirectEngine lookups return undefined rather than throwing, uBlock/src/js/redirect-engine.js:171-219) — a moved hook degrades, it doesn't crash. Privileged scriptlets are trust-gated: tokenRequiresTrust() (uBlock/src/js/redirect-engine.js:197-200) flags them, and normalizeRawFilter() drops such a filter when the source isn't trusted (uBlock/src/js/scriptlet-filtering-core.js:36-47); trust is an origin/prefix allow-list defaulting to 'ublock-' (uBlock/src/js/background.js:87), so only uBO's own first-party lists qualify. Injected code additionally runs inside a try/catch IIFE with MAIN/ISOLATED world isolation (uBlock/src/js/scriptlet-filtering-core.js:77-83). Takeaway: a remote list selects among pre-shipped behaviours and supplies parameters; it is never executable code. That is exactly the boundary AMO requires and the boundary our rescue config must respect.
2.2 SponsorBlock: server-driven data, but client-side resilience¶
A common misconception is that SponsorBlock has a "config server" that pushes selectors. It does not. Two separate mechanisms:
Its server serves crowd-sourced data, not client config or selectors. asyncRequestToServer() (SponsorBlock/src/utils/requests.ts:12) picks the server address from Config and delegates to maze-utils' background request proxy:
export async function asyncRequestToServer(type, address, data = {}, headers = {}): Promise<FetchResponse> {
const serverAddress = Config.config.testingServer ? CompileConfig.testingServerAddress : Config.config.serverAddress;
return await (sendRequestToCustomServer(type, serverAddress + address, data, headers));
}
Segments are fetched by hash prefix, not full video ID: the client sends only the first 5 hex chars of SHA-256(videoID) and filters the response client-side (getHash(videoID,1).slice(0,5) → GET /api/skipSegments/<hashPrefix>, SponsorBlock/src/utils/segmentData.ts:62-70), and treats any non-200/404 as a soft failure (returns null, no throw, :89). This k-anonymity design means the server can't learn exactly which video a user watched. This is the privacy bar to match if we ever fetch anything keyed on user activity.
Its "config" is local storage, versioned + migrated — no network selectors. maze-utils' ProtoConfig.fetchConfig() reads only chrome.storage.sync / chrome.storage.local (maze-utils/src/config.ts:174-198). Schema evolution across versions is a pure client-side migration function: SponsorBlock passes migrateOldSyncFormats (SponsorBlock/src/config.ts:189) into setupConfig() (maze-utils/src/config.ts:200-209), which normalises old stored shapes on load. Debug export sanitises identity (generateDebugDetails deletes userID, masks serverAddress, SponsorBlock/src/config.ts:625).
DOM resilience is 100% client-side feature detection + fail-safe. This is the pattern to copy for our DOM hooks:
- Ordered multi-selector fallback, actively dated. SponsorBlock's own source is full of selector cascades that are patched whenever YouTube's markup shifts, with inline dates marking each addition: the progress-bar attach point tries newer-mobile (Sept 2024), newer-mobile (May 2024), desktop, Invidious/VideoJS, YT Music, Piped, and YTTV selectors in order and returns
nullif none match (SponsorBlock/src/content.ts:537-585); the controls container (SponsorBlock/src/utils/pageUtils.ts:5-27) and the 11-entry player reference-node list (SponsorBlock/src/utils.ts:233-263) follow the same shape. The maze-utils primitive behind them,findValidElementFromSelector(selectors[]), returns the first visible match (maze-utils/src/dom.ts:57,findValidElementFromGeneratorat:65); the channel-ID resolver chains YouTube → Embed → Invidious → Mobile selectors (maze-utils/src/video.ts:429-432). The lesson: never one selector — an ordered list, first-visible-wins,nullon miss. - Async wait instead of assume-present.
waitForElement(selector)resolves via a sharedMutationObserverand never throws if the element is late (maze-utils/src/dom.ts:106). - Re-attach when YouTube swaps the player (SPA navigation).
setupVideoMutationListener()observes.html5-video-containerand rebuilds the observer when the element goes invisible (maze-utils/src/video.ts:456-490);refreshVideoAttachments()re-finds the<video>viawaitForElement("video", true)and handles the miniplayer case (maze-utils/src/video.ts:504-520);waitForVideo()queues callers until a video exists (:490). - Visibility validation, not blind
querySelector.isVisible/isVisibleOrParentgate every match (maze-utils/src/dom.ts:44-55), so a hidden/stale node is skipped rather than used.
Contrast with our current code. js/youtube_audio.js:15 does document.getElementsByTagName('video')[0] with no guard — if YouTube renders the <video> late or renames the container, videoElement is undefined and the handler throws. That is the exact class of brittleness SponsorBlock engineered away.
Player-state hook (relevant to our reimagined InnerTube path). SponsorBlock reads live player state by injecting a script into the page's MAIN world (MV3 declares world:"MAIN"; MV2 injects a <script> tag — SponsorBlock/src/document.ts → maze-utils/src/injected/document.ts, wired at SponsorBlock/src/content.ts:122-136). That script listens to YouTube's own custom events (yt-player-updated, yt-navigate-start, yt-navigate-finish) and calls the player's API (document.getElementById("movie_player").getVideoData() etc.), posting results back via window.postMessage. Crucially it degrades: getYouTubeVideoID() falls back to URL parsing then DOM-anchor scraping when the hook hasn't fired, and direct InnerTube (youtubei/v1/player) calls in maze-utils' metadataFetcher.ts are wrapped in a swallowing try/catch that returns null-filled metadata rather than throwing. For us this says two things: hook YouTube's own events rather than polling blindly, and make every player/InnerTube read fail-soft — which also makes it recordable as a fixture (see §3).
Build-time list generation (a third pattern). SponsorBlock's Invidious/Piped instance list is regenerated monthly by CI and baked into the package (SponsorBlock/ci/generateList.ts, header: "should not be shipped with the extension"; .github/workflows/updateInvidous.yml cron 0 0 1 * * → auto-PR). This is the middle ground: not runtime-fetched, but not hand-maintained either. Useful for data that changes slowly (e.g. a curated default selector set) where a release cadence is acceptable.
2.3 A concrete remote-hotfix design for our extension (ghost-safe)¶
Goal: fix a YouTube breakage (renamed param, moved selector, a sub-feature that started interfering) without a signed release, without any telemetry, and without ever executing remote code.
Artifact. One small JSON, rescue.json, hosted on ≥2 independent free static hosts (GitHub Pages + jsDelivr from a public repo, mirroring uBO's cdnURLs). Strict, versioned, declarative schema:
{
"schema": 1,
"version": 42, // monotonic; client ignores older
"minExtVersion": "0.1.0", // compat gate
"expires": 21600, // TTL seconds (client re-checks no sooner)
"audio": {
"requestMatch": "mime=audio", // substring that marks the audio stream request
"excludeMatch": ["live=1"], // skip these
"stripParams": ["range", "rn", "rbuf"]
},
"selectors": { // ordered fallbacks, consumed like maze-utils findValidElementFromSelector
"video": ["video.html5-main-video", "#movie_player video", "video"],
"playerContainer": [".html5-video-container", "#movie_player"]
},
"flags": { // kill-switches; fail-open
"audioOnly": true,
"ghostStealth": true,
"adBlock": true
},
"ops": [ // uBO-scriptlet-style: reference PRE-SHIPPED handlers by id
{ "id": "strip-request-params", "when": "audio.request", "args": { "params": ["range","rn"] } }
],
"notice": { "since": "2026-07-10", "text": "A YouTube change is being worked on." }
}
Hard rules (this is what keeps it legal + ghost-safe):
- Data, never code.
ops[].idselects among handlers already shipped in the signed package (the uBO trusted-scriptlet boundary,static-filtering-parser.js:2346);argsare strings/numbers only. The JSON is never passed toeval/new Function/import(). This is what AMO permits — the policy prohibits "load remote code for execution" (extensionworkshop.com), not fetching declarative data; uBO's globally-deployed practice is the proof of interpretation. - Cache-first, fail-open. Bundle a
rescue.jsoninside the extension as last-known-good (uBO's localcontentURLfallback). On fetch: validate schema +version+minExtVersion; if valid, store instorage.local; if the fetch fails, times out, or fails validation, keep the last-good copy (getRemotestaleness/fallback,assets.js:1044). Never block YouTube on the network. If every config is unavailable, run the shipped defaults. - Ghost fetch discipline.
fetch(url, { credentials: 'omit', cache: 'no-cache' })— no cookies, no custom headers, no query params derived from user data, no unique/per-install ID. Every user's request is byte-identical: it reveals only "an install checked for updates," nothing about the user or which video. (SponsorBlock still hashes even the video ID; we send nothing about the user at all.) Reject non-JSON / HTML bodies (uBOfetchTextguard,assets.js:371). Enforce a small size cap and HTTPS-only. - Integrity beyond TLS. Because AMO forbids remote code, the file is inert data — but a compromised CDN could still feed bad selectors. Ship a public key in the package and verify a detached signature over
rescue.json(Ed25519 via WebCrypto); reject on mismatch and fall back to last-good. (uBO already validates every network-fetched patch with a SHA-1 checksum before applying it,uBlock/src/js/diff-updater.js:156-181— same principle.) At minimum, strict schema validation + reject-unknown-fields. - Throttle. Re-check at most every
expiresseconds (and never more than, say, hourly), mirroring uBO's per-assetExpires+ 2-min inter-fetch floor (assets.js:1184,:1399). - Rollback is instant. A bad rescue config is fixed by pushing a corrected JSON (bump
version) — seconds, not an AMO review cycle. The kill-switchflagslet us disable a misbehaving sub-feature remotely while a real fix ships.
Risk table.
| Risk | Mitigation (with precedent) |
|---|---|
| CDN outage | Bundled last-known-good + ≥2 mirrors (uBO contentURL local + cdnURLs[]) |
| Stale config | TTL/expires + Last-Modified staleness check (assets.js:1044) |
| Compromised host feeds bad selectors | Ed25519 signature + strict schema + fail-open so worst case is "audio-only off," not "YouTube broken" |
| AMO rejection | Config is declarative data, not code — policy-compliant; uBO ships this at scale |
| Privacy leak / de-ghosting | Static credential-less GET, no IDs, identical for all users |
| Poisoned config breaks YouTube | Every hook try/catch + fail-open + kill-switch + instant rollback |
3. Test strategy — the determinism ladder¶
The environment problem is real: live YouTube brings ads, autoplay gating, consent walls, and bot checks (all visible in tests/e2e/verify-firefox.mjs:99-112, :135). The fix is not "make live YouTube deterministic" (impossible) but push almost all proof below the live layer and keep the live layer as a non-gating detector.
| Layer | Environment | What it proves | Deterministic? | CI gate? |
|---|---|---|---|---|
| 1 Unit | jsdom + chrome mock, imports real js/ | Each shipped function's logic (param stripping, request classification, selector resolution, rescue-config validation) | Yes | Yes |
| 2 Integration | jsdom, both scripts loaded, mock messaging | background webRequest→message→content src swap wired together | Yes | Yes |
| 3 Fixture replay | jsdom + recorded YouTube data | Player-response parsing & DOM hooks against a frozen real YouTube shape | Yes | Yes |
| 4 Packaged E2E, fake YouTube | real Firefox + local page mimicking the player contract | The real signed .xpi (manifest, permissions, injection, webRequest) works in a real browser | Yes | Yes (push or nightly) |
| 5 Live smoke | real Firefox + real YouTube | The whole path still works against the actual moving target | No | No — nightly canary, alert-only |
Why layer 4 is the one we're missing. Today we jump from unsound unit tests straight to live YouTube. Layer 4 loads the actual built extension in a real Firefox (reusing the existing geckodriver plumbing) but points it at a local HTML page that reproduces YouTube's contract: a <video> element plus a stub endpoint that emits a …googlevideo…mime=audio… request. That deterministically exercises manifest wiring, content-script injection at document_start, the webRequest blocking listener, and the src swap — everything except YouTube's unpredictability. It is real and reproducible.
What each of our three concrete targets maps to:
- (a) webRequest / telemetry-blocking logic → Layer 1. Call
processRequest(details)with recorded request-detail fixtures (real googlevideo URLs, telemetry URLs like/api/stats,/youtubei/v1/log_event,/ptracking). Assert the audio path emits the stripped URL and the telemetry path is cancelled. Pure function, zero network. (uBO tests its network engine exactly this way against fixture requests:uBlock/platform/npm/tests/request-data.js,snfe.js.) - (b) page-context player/response hook → Layers 1+3. Unit-test the parser against a saved
ytInitialPlayerResponse.jsonfixture; integration-test the injected hook against a frozen watch-page DOM. Refresh the fixture periodically from a real page (a maintainer task, not a test-time fetch). - (c) audio-only behaviour → Layers 2+4. Layer 2: in jsdom, feed the content-script handler a
mime=audiomessage and assertvideo.srcswaps and the banner is appended/removed (js/youtube_audio.js:13-45). Layer 4: confirm the same in real Firefox against the local fake page (proves MSEblob:→direct-URL swap actually takes in a real media element).
CI shape (extend .github/workflows/ci.yml): jest (layers 1–3) on every push, headless, no network; layer 4 on push or nightly (needs a browser but is deterministic); layer 5 nightly with retries, continue-on-error, and an issue/alert on failure — this is how the maintainer learns of breakage server-side, with zero user telemetry.
3.1 How uBO & SponsorBlock actually test (evidence)¶
SponsorBlock — deterministic unit/fixture tests that import real source; live Selenium disabled. - SponsorBlock/jest.config.js: ts-jest, roots: ["test"], github-actions reporter. - test/urlParser.test.ts:1 imports the real getStartTimeFromUrl and drives it with pure string fixtures (:4-26). - test/exporter.test.ts:1-6 (@jest-environment jsdom) imports the real exportTimes/importTimes and round-trips fixture segment objects. - test/previewBar.test.ts:1-12 imports the real PreviewBar class and exercises createChapterRenderGroups with fixture segments in jsdom. - test/selenium.test.ts:6 — the live Chrome test is xtest(...) (skipped by default), self-skips if no browser is installed (:9-24), loads the built extension via --load-extension=../dist/, hits a real ad-free video (jNQXAC9IVRw), and on failure dumps the live page source to test-results/source.html specifically to diagnose YouTube layout drift (SponsorBlock/test/selenium.test.ts:44-51). Real-browser E2E is explicitly opt-in and non-gating. - .github/workflows/tests.yml runs npm run test (build + jest) on push/PR and even sets up a WireGuard VPN (to reach YouTube from a non-blocked region) — yet the Selenium test still doesn't run in normal CI because it's xtest. The deterministic gate is the three pure-unit suites; ci.yml is build + lint only.
uBO — node-runner tests that import the real packaged engine + fixtures; plus manual HTML harnesses. - uBlock/platform/npm/tests/snfe.js:26-51 imports the real engine via createWorld('./index.js') (esm-world gives each test a fresh module world), then loads real filter lists with engine.useLists([...]) and asserts match results with assert.strict. This is the canonical "test the shipped module, not a copy" pattern. - Sibling suites (Mocha entry uBlock/platform/npm/test.js:29-50): wasm.js feature-detection test — asserts enableWASM() resolves true with WebAssembly present and false when the sandboxed global is undefined (uBlock/platform/npm/tests/wasm.js:34-52), the exact "hook-missing → degrade, don't throw" contract; leaks.js (global-namespace smoke); and request-data.js — a golden-file replay (opt-in --full-battery) that loads frozen snapshots of real EasyList/EasyPrivacy/uBlock lists (tests/data/bundle.tgz) plus a frozen corpus of real captured request URLs (scaling-palm-tree/requests.json) and asserts the engine reproduces precomputed verdicts in data/results.json (uBlock/platform/npm/tests/request-data.js:105-113). This golden-file-replay-against-frozen-real-data pattern is the model for our fixture layer (§3, layers 1+3). - uBlock/docs/tests/ holds manual browser HTML harnesses (scriptlet-injection-filters-1.html, procedural-cosmetic-filters.html, static-filtering-parser-checklist.txt) for behaviour that can't run headless. - The root package.json test script is a deliberate no-op stub (echo "Error: no test specified" && exit 1) — automated tests live only in platform/npm/. .github/workflows/main.yml is a release/build pipeline (triggered on tag creation; clone uAssets, build MV2/MV3 packages), not a live-site or test gate.
Both projects converge on the same lesson: deterministic CI = import real modules + feed fixtures; live-browser tests exist but are opt-in/non-gating.
4. Fixing our unsound jest setup¶
4.1 Diagnosis (verified)¶
tests/unit/global.test.js:16-91 re-declares copies of removeURLParameters, reloadTab, processRequest, enableExtension, disableExtension, saveSettings inside beforeEach — it never imports js/global.js. So: - Coverage of js/** is 0% even though jest.config.js:5 collects it. The tests exercise a parallel universe. - jest.config.js:10-17 sets all coverage thresholds to 0, with a comment admitting "IIFE patterns making direct import difficult." This directly contradicts the project's own mandate of 90% coverage + TDD (docs/agent-instructions/02-testing-and-validation.md:7-39, :249). - A refactor of js/global.js cannot break these tests, and a bug in the shipped code cannot fail them. Green is meaningless.
The blocker is real: js/global.js and js/youtube_audio.js are bare scripts that call chrome.* at top level (js/global.js:70, :86, :100, :104; js/youtube_audio.js:1), so a naive require('../../js/global.js') would execute side effects against nothing.
4.2 The fix — make the real source importable, guard the bootstrap¶
Split each file into pure, exported functions + a main() bootstrap that only auto-runs in the extension runtime. In the browser MV2 background/content context module is undefined, so the export guard is inert; under jest (CJS via babel-jest, already a devDependency) module.exports exists, so tests import the real functions and choose when to run the wiring. This ships unchanged to Firefox.
js/global.js (illustrative diff — logic identical, structure testable):
const tabIds = new Set();
function removeURLParameters(url, parameters) { /* …unchanged… */ }
function reloadTab() { /* …unchanged… */ }
function processRequest(details) { /* …unchanged… */ }
function enableExtension() { /* …unchanged… */ }
function disableExtension() { /* …unchanged… */ }
function saveSettings(currentState) { /* …unchanged… */ }
// All top-level side effects move here (the onClicked listener, the initial
// storage.get bootstrap, onMessage, onRemoved).
function main() {
chrome.browserAction.onClicked.addListener(/* … */);
chrome.storage.local.get('youtube_audio_state', /* … */);
chrome.runtime.onMessage.addListener((m, sender) => tabIds.add(sender.tab.id));
chrome.tabs.onRemoved.addListener((id) => tabIds.delete(id));
}
// Runtime (Firefox): module is undefined → wire up. Test (jest/CJS): export instead.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { tabIds, removeURLParameters, reloadTab, processRequest,
enableExtension, disableExtension, saveSettings, main };
} else {
main();
}
A sound test that yields real coverage of js/global.js:
const g = require('../../js/global.js'); // real source, instrumented by babel-jest
describe('processRequest (real source)', () => {
it('strips range/rn/rbuf and messages the tab for an audio request', () => {
g.tabIds.add(1);
g.processRequest({ tabId: 1,
url: 'https://r1.googlevideo.com/videoplayback?mime=audio&range=0-1&rn=2&rbuf=3' });
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(1,
{ url: 'https://r1.googlevideo.com/videoplayback?mime=audio' });
});
it('ignores live streams', () => {
g.tabIds.add(1);
g.processRequest({ tabId: 1, url: 'https://x/y?mime=audio&live=1' });
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
});
it('wires listeners on main()', () => {
g.main();
expect(chrome.browserAction.onClicked.addListener).toHaveBeenCalled();
});
});
The existing tests/setup.js chrome mock is good and is reused verbatim — only the tests change (import instead of re-declare). For the content script, apply the same guard to js/youtube_audio.js (wrap the top-level chrome.runtime.sendMessage + onMessage.addListener in main()), then unit-test the message handler in jsdom against a fixture DOM (a <video> + parent nodes), asserting the src swap and banner injection (js/youtube_audio.js:13-45).
Then ratchet coverage. Once tests import real source, replace the 0 thresholds in jest.config.js:10-17 with real numbers and raise toward the mandated 90% (docs/agent-instructions/02-testing-and-validation.md:11). Delete the re-declared function bodies from tests/unit/global.test.js. Coverage now reflects the shipped code.
(Alternative considered and rejected: loading the file via vm.runInThisContext(fs.readFileSync(...)). It can execute the script with a pre-injected chrome, but it bypasses babel/istanbul instrumentation, so it produces no coverage — defeating the goal. Importing through the transformer is required for meaningful coverage.)
5. Breakage monitoring without telemetry¶
No automatic phone-home. Four complementary, ghost-safe channels:
- Local self-diagnostics + a "degraded" flag. The extension already stamps
data-yta-*attributes ondocumentElement(read by the harness:data-yta-content-loaded,data-yta-msg-count,data-yta-last-url,data-yta-src-after-set,verify-firefox.mjs:143-146). Promote this to an in-extension health check: after enabling on a watch page, if the expected signal (amime=audiorequest / a successfulsrcswap) hasn't fired within N seconds, set an internaldegraded=trueinstorage.local. Purely local. - User-visible signal (the honest UX). On
degraded, show a toolbar badge/icon variant (uBO/SB use icon state; ourenableExtension/disableExtensionalready swap icons,js/global.js:46-63) and an options-page banner: "Audio-only may not be working here — YouTube may have changed. Check for an update or report it," linking to GitHub issues. Users become the reporting channel — the SponsorBlock/uBO community-issue model — with zero automatic data collection. - User-initiated diagnostics copy. A "Copy diagnostics" button that dumps local, non-identifying state (extension version, which hook failed, active selector-set version, browser UA) to the clipboard for pasting into an issue — modelled on SponsorBlock's
generateDebugDetails, which explicitly sanitisesuserIDand masksserverAddress(SponsorBlock/src/config.ts:614-636). Nothing leaves the machine unless the user pastes it. - Maintainer-side canary (server, not users). The nightly Layer-5 live smoke (
tests/e2e/verify-firefox.mjs) already emits a machine verdict (MECHANISM_FIRED/MECHANISM_DID_NOT_FIRE,:230-236). Run it as a scheduled GitHub Action; when the verdict flips, open an issue / email the maintainer. The maintainer learns of breakage from their own CI, then pushes arescue.jsonfix — the users' browsers report nothing. The rescue config'snoticefield (§2.3) can then surface "a known YouTube change is being worked on" in the options page, closing the loop declaratively.
6. Recommendation & prioritized checklist¶
Strategic recommendation. Combine uBO's remote declarative rescue config (hotfix without release) with SponsorBlock's client-side feature-detection + fail-open (survive until the hotfix lands), and prove both with a deterministic 4-layer test base topped by the existing live harness as a non-gating canary. The governing invariant everywhere: degrade to inert; never break YouTube.
Prioritized checklist:
- [P0] Make tests sound. Refactor
js/global.js+js/youtube_audio.jsto the export-guard/main()pattern (§4.2); rewritetests/unit/*to import real source; delete the re-declared copies. Proves the shipped code; unblocks everything. - [P0] Fail-open the DOM hooks. Guard
js/youtube_audio.js:15(no more unguardedgetElementsByTagName('video')[0]); wrap every YouTube touch intry/catch; adopt an ordered multi-selector resolver +waitForElement/MutationObserverre-attach (port the maze-utils pattern,dom.ts:57/:106,video.ts:456). Stops YouTube-change breakage from throwing. - [P1] Restore coverage gates. Replace the
0thresholds (jest.config.js:10-17) with real numbers ratcheting to 90% (02-testing-and-validation.md:11). - [P1] Add Layer 4 (packaged E2E vs local fake YouTube). Reuse geckodriver; serve a local page reproducing the
<video>+mime=audiorequest contract. Deterministic proof the real.xpiworks. - [P1] Add fixtures. Commit
tests/fixtures/: recorded request-detail objects (audio + telemetry URLs) and aytInitialPlayerResponse.json; unit-testprocessRequestand any player-response parser against them (Layers 1+3). - [P2] Ship the rescue-config client (§2.3): bundled last-good, ≥2 mirrors,
credentials:'omit', schema + signature validation, TTL throttle, fail-open. Externalize the URL matchers/selectors currently hard-coded injs/global.js:38-39. - [P2] Breakage UX:
degradedflag + options-page banner + "Copy diagnostics" (sanitised). - [P2] Nightly canary CI: run
verify-firefox.mjsscheduled,continue-on-error, alert on verdict flip. Keep it out of the blocking gate.
7. References¶
Cloned repositories (read directly): - gorhill/uBlock — https://github.com/gorhill/uBlock @ 697b2f1 (2026-07-10). Key: src/js/assets.js, src/js/diff-updater.js, src/js/storage.js, src/js/background.js, src/js/start.js, src/js/redirect-engine.js, src/js/scriptlet-filtering-core.js, src/js/static-filtering-parser.js, assets/assets.json, platform/npm/test.js, platform/npm/tests/{snfe,request-data,wasm,leaks}.js, docs/tests/, .github/workflows/main.yml. - ajayyy/SponsorBlock — https://github.com/ajayyy/SponsorBlock @ 4a118fb (2026-07-01). Key: src/utils/requests.ts, src/utils/segmentData.ts, src/config.ts, src/content.ts, src/utils.ts, src/utils/pageUtils.ts, src/document.ts, test/{urlParser,exporter,previewBar,selenium}.test.ts, jest.config.js, ci/generateList.ts, .github/workflows/{ci,tests,updateInvidous}.yml. - ajayyy/maze-utils — https://github.com/ajayyy/maze-utils @ 6b1ba69 (2026-06-09; = SponsorBlock's pinned submodule). Key: src/dom.ts, src/video.ts, src/config.ts, src/injected/document.ts, src/metadataFetcher.ts.
This repo: js/global.js, js/youtube_audio.js, manifest.json, tests/unit/global.test.js, tests/setup.js, jest.config.js, tests/e2e/verify-firefox.mjs, docs/agent-instructions/02-testing-and-validation.md.
Web (confirmed July 2026): - Mozilla Add-on Policies — "Add-ons must be self-contained and not load remote code for execution." https://extensionworkshop.com/documentation/publish/add-on-policies/ (governs the data-not-code boundary for the rescue config). - uBlock Origin differential filter-list updates (since v1.54.0; Diff-Path/Diff-Expires, diff-updater.js, ~5h for uBO filters, 5-day default, full-fetch fallback). uBO filter-list management: https://deepwiki.com/gorhill/uBlock/3.4-filter-list-management-and-updates; update cadence: https://superuser.com/questions/1935574/what-is-the-default-update-frequency-for-a-ublock-origin-filter-list.