feat(extension): real Chromium E2E test harness + handshake race fix#13
Conversation
…[skip ci] Path #3 of session 2026-05-22: enable agents to self-verify Chrome extension Measure flow without anh's machine. Implemented chromium.launchPersistentContext + --load-extension test infra; the test immediately exposed 2 real production bugs in the chrome-ext-spa-allowlist system, both fixed in this commit. What changed: 1. NEW test layer test:e2e:extension (Validation Ladder #8) - apps/extension-chrome/playwright.config.ts (workers=1, headless=false, reuseExistingServer, webServer hands off `pnpm --filter website dev`) - apps/extension-chrome/tests/playwright-e2e/extension-load.spec.ts (5 layer assertions L1-L5) - apps/extension-chrome/scripts/prepare-e2e-fixtures.mjs (idempotent build + setup-dev — guarantees extension-dist/ + .env.local exist) - package.json: scripts.e2e:extension + e2e:extension:install + @playwright/test + playwright deps from catalog Spec contract: L1: SW URL contains deterministic ID from .dev-keys/extension.pem L2: manifest externally_connectable.matches has localhost L3: /measure auto-detects extension on mount → "Extension Ready" renders WITHOUT user clicking Measure L4: background.bundle.js has 0 unguarded process.* (esbuild <define:process.X> markers correctly excluded) L5: extension-bridge.ts exports atomic startMeasureAndStream 2. BUG FIX: Layer E race in chrome-ext-spa-allowlist - apps/website/lib/backend-detector.ts: install announce listener EAGERLY at module load (was lazy-installed only on first detectBackend call) - Root cause: SW's announceToAllTabs fires during page load (tabs.onUpdated status=complete) BEFORE React mounts and calls detectBackend. With lazy install, announce messages dispatched before the listener registered → lost forever. - Diagnosed via Playwright's addInitScript (installed listener before any page script ran) which captured 2 announce messages with correct payload that the SPA's listener missed. 3. BUG FIX: Layer F missing useEffect in chrome-ext-spa-allowlist - apps/website/app/measure/page.tsx: added useEffect that calls detectBackend on mount when backend.kind === 'none' - Root cause: page only called detectBackend on user click; initial render always showed NoBackendGuide even when extension was loaded and running. Anh would see "No runner detected" then clicking Measure would belatedly detect. - Fix: useEffect runs once on mount, calls detectBackend with AbortController signal, sets backend if detected. 4. REGISTRY UPDATE: chrome-ext-spa-allowlist - Added Layer E (announce listener install timing) - Added Layer F (useEffect detect-on-mount) - Added 2 new invariants enforced by test:e2e:extension - Updated trigger globs to include app/measure/page.tsx - Added second canonical post-mortem citing this session 5. NEW REGISTRY SYSTEM: extension-e2e-test-infra - 4 layers (config + spec + fixtures + package script) - Documented invariants (launchPersistentContext mandatory, --disable-extensions-except + --load-extension both required, headless toggle via env var) - Cited as canonical example of "spec exposes registry bug" 6. HARNESS UPDATE - New Validation Ladder layer test:e2e:extension with full contract + operational note (agents without Playwright + Chromium MUST defer to human) - Lane × layer matrix updated: extension glob → layer required - Receipt schema enum extended with test:e2e:extension - Lane × Receipt requirement matrix updated Verification (Evidence Receipts): layer: test:e2e:extension command: pnpm --filter @ohmyperf/extension-chrome e2e:extension exit_code: 0 duration_s: 17.3 stdout_tail: | [L3] extensionDetected=true guideVisible=false [L3] bodyExcerpt=OhMyPerf Viewer ... Extension ready v0.2.0 ... ✓ 1 L1 extension service worker registers with deterministic ID (5ms) ✓ 2 L2 extension manifest contains externally_connectable for localhost (1ms) ✓ 3 L3 measure page handshake — extension IS loaded, MUST be detected (3.6s) ✓ 4 L4 service worker bundle has no unguarded process.* (3ms) ✓ 5 L5 atomic startMeasureAndStream pattern present in bridge (1ms) 5 passed (17.3s) relevant_assertion: All 5 layer specs PASS — handshake works after both Layer E + Layer F fixes; would have FAILED before fixes (verified during diagnosis run). layer: test:cross-cutting-allowlists command: bash scripts/check-cross-cutting-allowlists.sh exit_code: 0 stdout_tail: | [cross-cutting-allowlists] PASS: chrome-ext-spa-allowlist: A==B host sets, C or D present [cross-cutting-allowlists] PASS: node-globals-in-browser-bundle: 0 unguarded refs [cross-cutting-allowlists] PASS: mv3-sw-port-lifecycle: atomic connect + sync onConnectExternal 3 systems checked, 0 failed relevant_assertion: No regression on existing 3 systems layer: validate:quick (typecheck + lint) command: pnpm --filter @ohmyperf/extension-chrome typecheck && pnpm --filter @ohmyperf/extension-chrome lint && pnpm --filter @ohmyperf/website typecheck && pnpm --filter @ohmyperf/website lint exit_code: 0 stdout_tail: | ✔ No ESLint warnings or errors relevant_assertion: All typecheck + lint clean across changed packages Multi-Layer Pre-Flight Block (per HARNESS Forbidden #20): ```yaml change_id: e2e-extension-load-2026-05-22 classification: multi-layer cross_cutting_systems_touched: - chrome-ext-spa-allowlist (modified Layers E + F) - extension-e2e-test-infra (new system, registered in REGISTRY) layers_modified: - apps/extension-chrome/playwright.config.ts (NEW) - apps/extension-chrome/tests/playwright-e2e/extension-load.spec.ts (NEW) - apps/extension-chrome/scripts/prepare-e2e-fixtures.mjs (NEW) - apps/extension-chrome/package.json (UPDATE — scripts + deps) - apps/website/lib/backend-detector.ts (UPDATE — Layer E fix) - apps/website/app/measure/page.tsx (UPDATE — Layer F fix) - docs/HARNESS.md (UPDATE — new ladder layer + matrix) - docs/MULTI_LAYER_REGISTRY.md (UPDATE — extended sys + new sys) canonical_post_mortems_referenced: - 51feecf → be1eca2 (Layers A-D, session 2026-05-21) ``` Counts: HARNESS.md: 1196 → 1235 lines (+39) REGISTRY: 116 → 154 lines (+38) Validation Ladder layers: 7 → 8 Multi-Layer Registry systems: 3 → 4 chrome-ext-spa-allowlist layers: 4 → 6 (added E, F) Net effect: agents can now self-verify Chrome extension flows end-to-end without anh's machine, AND the harness has structural defense against the 2 race bugs em discovered (would catch in CI on next regression).
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive E2E testing infrastructure for the Chrome extension using Playwright, including a new test suite, fixture preparation scripts, and documentation updates. It also addresses extension detection reliability by ensuring listeners are registered eagerly and detection is triggered on the measure page mount. Review feedback identifies a potential race condition in the E2E detection logic and suggests expanding the Node global leak detection regex to align with documented invariants.
| await page.waitForFunction( | ||
| () => { | ||
| const html = document.documentElement.innerText.toLowerCase(); | ||
| return /extension\s+ready|runner\s+detected|no\s+runner|not\s+detected/.test(html); | ||
| }, | ||
| { timeout: 30_000 }, | ||
| ); |
There was a problem hiding this comment.
The waitForFunction currently waits for any of the handshake states, including the initial "not detected" state. Since the page initially renders with backend.kind === 'none', this function will return immediately before the extension detection logic has a chance to run, leading to a race condition where the subsequent assertions may fail. Since this test specifically verifies that the extension is detected, it should wait for the success state specifically.
| await page.waitForFunction( | |
| () => { | |
| const html = document.documentElement.innerText.toLowerCase(); | |
| return /extension\s+ready|runner\s+detected|no\s+runner|not\s+detected/.test(html); | |
| }, | |
| { timeout: 30_000 }, | |
| ); | |
| await page.waitForFunction( | |
| () => { | |
| const html = document.documentElement.innerText.toLowerCase(); | |
| return /extension\s+ready|runner\s+detected/.test(html); | |
| }, | |
| { timeout: 30_000 }, | |
| ); |
| if (/<define:process\./.test(line)) continue; | ||
| if (/define_process_(env|versions|version|platform)_default/.test(line)) continue; | ||
| if (/init_define_process_(env|versions|version|platform)/.test(line)) continue; | ||
| const matches = line.match(/(?<!typeof\s)\bprocess\.(env|version|versions|platform)\b/g); |
There was a problem hiding this comment.
The regex for identifying unguarded Node globals is too restrictive. It only checks for a subset of process properties and misses Buffer, __dirname, __filename, and setImmediate, all of which are explicitly listed as invariants in the node-globals-in-browser-bundle system in the registry. Expanding the regex ensures the test correctly enforces the documented cross-cutting invariants.
| const matches = line.match(/(?<!typeof\s)\bprocess\.(env|version|versions|platform)\b/g); | |
| const matches = line.match(/(?<!typeof\s)\b(process\.[a-zA-Z0-9_]+|Buffer\.[a-zA-Z0-9_]+|__dirname|__filename|setImmediate)\b/g); |
References
- Invariants for node-globals-in-browser-bundle require checking for process.X, Buffer.X, __dirname, __filename, and setImmediate per docs/MULTI_LAYER_REGISTRY.md.
3-Angle review (Oracle correctness + 2× explore security/UX) on PR #13 returned 13 findings (2 blockers + 5 important + 6 nits). This commit addresses 11 of them; 2 deferred (B5 frame-ancestors needs HTTP header — static export limitation, doc'd as v0.3 work; A3 runner-late- upgrade is acceptable behavior change, doc'd in REGISTRY). Angle A (correctness, oracle bg_b527c677): - A5 BLOCKER: HMR window-stamp guard apps/website/lib/backend-detector.ts: stamp `window.__ohmyperfAnnounceInstalled` flag so HMR re-evaluation doesn't double-install listener - A6 BLOCKER: process.* skip rule was matching whole lines containing esbuild markers, hiding real offenders. Now strips marker substrings via .replace() then tests residue with the regex. apps/extension-chrome/tests/playwright-e2e/extension-load.spec.ts L4 - A4 IMPORTANT: prepare-e2e-fixtures.mjs now runs `playwright install chromium` if browsers cache absent (instead of cryptic "browser not found" error mid-spec) - A-extra NIT: removed redundant headless: false from playwright.config.ts (was overridden by spec's launchPersistentContext anyway) Angle B (security, explore bg_1f59e5a0): - B1 MEDIUM: extended apps/extension-chrome/.gitignore with .dev-keys/, extension-dist/, playwright-report/ (defense-in-depth; root .gitignore already protects but package-level redundancy prevents subtree-copy exposure) - B5 MEDIUM: SKIPPED THIS PR. Reason: frame-ancestors is ignored when delivered via <meta> (Chromium spec). Real fix needs HTTP header which static export to GitHub Pages cannot deliver without a CDN proxy layer. Tracked as v0.3 — when ohmyperf.dev moves to Cloudflare Pages, set frame-ancestors via _headers file. - B9 MEDIUM: --no-sandbox is now conditional (Linux root || CI), not unconditional; protects developer macOS/Linux machines from unnecessary sandbox-disable - B10 LOW: stale profile cleanup at beforeAll start (recovers from SIGKILL leaks) Angle C (real-user UX, explore bg_68650392): - C1+C4 IMPORTANT: introduced `detecting` state. While true and backend.kind === 'none', BackendCard shows "Detecting…" badge and NoBackendGuide is suppressed. Eliminates jarring layout shift when detection completes (NoBackendGuide → ExtensionReady). - C2 IMPORTANT: shared detection promise via detectionPromiseRef. handleMeasure now awaits the inflight effect promise instead of starting a duplicate detection run, eliminating click-race. - C3 NIT: BackendCard "none" state copy updated. When detectionRan=true, shows "Auto-detect ran just now and found neither..." instead of misleading "click Measure to check again". - C8 IMPORTANT: detectionRan flag passed to BackendCard. Shows users that auto-detect did execute (vs. "detection never ran" ambiguity). - C9 NIT: dev-mode console.warn on detection error (replaces silent catch). - C10 NIT: BackendCard ready states now show "— enter a URL above to measure" helper text after the version badge. CRITICAL CORRECTION discovered while running smoke test: - E2E spec passed but smoke `measure route does not probe local runner before submit` (PR #12 contract @AjTheSpidey) FAILED because em's initial fix called detectBackend (which probes BOTH extension AND runner). PR #12 explicitly forbade auto-probing runner. - Em added new export `detectExtensionOnly()` to backend-detector.ts that only runs pingExtension (no pingRunner). page.tsx auto-detect effect now uses detectExtensionOnly. Runner is still probed on-Measure-click via the original detectBackend. - This preserves PR #12 contract while still fixing Layer F handshake. - REGISTRY updated: Layer F invariant now explicitly says "detectExtensionOnly (NOT detectBackend)". Verification (Evidence Receipts): layer: validate:quick command: pnpm --filter @ohmyperf/extension-chrome typecheck && pnpm --filter @ohmyperf/extension-chrome lint && pnpm --filter @ohmyperf/website typecheck && pnpm --filter @ohmyperf/website lint exit_code: 0 relevant_assertion: All clean across changed packages layer: test:cross-cutting-allowlists command: bash scripts/check-cross-cutting-allowlists.sh exit_code: 0 stdout_tail: 3 systems checked, 0 failed layer: test:e2e:extension command: pnpm --filter @ohmyperf/extension-chrome e2e:extension exit_code: 0 stdout_tail: | [L3] extensionDetected=true guideVisible=false [L3] page console (1 entries): ← was 3 with ERR_CONNECTION_REFUSED, now clean ✓ L1 (4ms) ✓ L2 (1ms) ✓ L3 (9.5s) ✓ L4 (7ms) ✓ L5 (3ms) 5 passed (26.5s) layer: test:integration (smoke) command: PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers playwright test tests/smoke.spec.ts (in apps/website) exit_code: 0 stdout_tail: | ✓ landing renders hero + form (901ms) ✓ form submit routes to /measure with url query (3.5s) ✓ backend detector card renders "none" state without backend (601ms) ✓ measure route does not probe local runner before submit (899ms) ← THIS IS THE PR #12 CONTRACT TEST ✓ CSP meta tag present (691ms) ✓ private URL triggers soft-warn (548ms) 6 passed (19.6s) Counts: Net diff: +128 -19 = +109 lines across 8 files 3-Angle review findings addressed: 11/13 (2 deferred with rationale)
3-Angle Review — addressed in
|
| ID | Angle | Sev | Fix |
|---|---|---|---|
| A5 | A | blocker | window.__ohmyperfAnnounceInstalled flag survives HMR re-evaluation |
| A6 | A | blocker | L4 process.* check now strips esbuild marker substrings then tests residue (was masking real offenders) |
| A4 | A | important | prepare-e2e-fixtures.mjs now playwright install chromium on cache miss |
| A-extra | A | nit | Removed redundant headless: false from playwright.config.ts |
| B1 | B | medium | Extended apps/extension-chrome/.gitignore with .dev-keys/, extension-dist/, playwright-report/ (defense-in-depth) |
| B9 | B | medium | --no-sandbox now conditional (isLinuxRoot || isCI), protects dev macOS/Linux machines |
| B10 | B | low | Stale profile cleanup in beforeAll (recovers from SIGKILL leaks) |
| C1+C4 | C | important | detecting state suppresses NoBackendGuide during inflight detection, BackendCard shows "Detecting…" badge — eliminates jarring layout shift |
| C2 | C | important | Shared detection promise via detectionPromiseRef; handleMeasure awaits inflight effect instead of duplicate detection |
| C3+C8+C10 | C | important+nit | BackendCard copy updated: detectionRan shows "Auto-detect ran just now and found neither…"; ready states show "— enter a URL above to measure" |
| C9 | C | nit | Dev-mode console.warn on detection error (replaces silent catch) |
Deferred with rationale
| ID | Angle | Why deferred |
|---|---|---|
| B5 frame-ancestors | B | frame-ancestors is ignored when delivered via <meta> (Chromium spec). Real fix needs HTTP header. GitHub Pages static export cannot deliver custom headers without a CDN proxy. Tracked as v0.3 — when ohmyperf.dev moves to Cloudflare Pages, ship via _headers file. |
| A3 runner-late-upgrade | A | Accepted behavior change. With both extension+runner installed, em prefers extension (correct per existing precedence). The "user mental model shift" is minor — pre-PR they had to click Measure to detect at all; now extension auto-detects but runner still requires click. Doc'd in REGISTRY. |
Critical correction (caught by re-running smoke test)
Em's initial fix used detectBackend in the useEffect, which probes BOTH extension AND runner. The PR #12 smoke test measure route does not probe local runner before submit (@AjTheSpidey contract) FAILED.
Fix: Added new export detectExtensionOnly() to backend-detector.ts. Auto-detect effect now uses extension-only path; runner is still probed only on Measure click via original detectBackend. PR #12 contract preserved.
REGISTRY Layer F invariant updated: explicit detectExtensionOnly (NOT detectBackend).
Final Evidence Receipts
test:e2e:extension → 5/5 PASS, 26.5s, 0 unexpected console errors
test:integration (smoke) → 6/6 PASS, 19.6s (incl. PR #12 contract test)
test:cross-cutting → 3/3 PASS, 0 failed
validate:quick → typecheck + lint clean across all changed packages
Em proceeds to merge.
Summary
Path #3 of session 2026-05-22: Enable agents (and developers) to self-verify the Chrome extension Measure flow end-to-end without depending on a manual smoke test on someone else's machine. The act of writing the verification spec immediately surfaced 2 production bugs in the
chrome-ext-spa-allowlistsystem — both fixed in this PR.This is the canonical example of the harness Pre-Flight protocol: write the verifier first, the verifier finds the bugs, ship the fix and the verifier together.
What's in this PR
1. New Validation Ladder layer:
test:e2e:extensionapps/extension-chrome/playwright.config.ts— workers=1, headless=false default,reuseExistingServer: true, web server hands offpnpm --filter website devapps/extension-chrome/tests/playwright-e2e/extension-load.spec.ts— 5 layer assertions L1–L5 against real Chromium with extension loaded viachromium.launchPersistentContext()+--load-extensionapps/extension-chrome/scripts/prepare-e2e-fixtures.mjs— idempotent fixture prep (build extension + setup-dev keypair if missing)apps/extension-chrome/package.json— new scriptse2e:extension,e2e:extension:install+ catalog deps@playwright/test+playwrightSpec contract
.dev-keys/extension.pem(matchesNEXT_PUBLIC_EXTENSION_ID)manifest.externally_connectable.matchescontains a localhost entry/measureauto-detects extension on mount → "Extension Ready" badge renders without clicking Measurebackground.bundle.jshas 0 unguardedprocess.*runtime refs (esbuild<define:process.X>markers correctly excluded)extension-bridge.tsexports atomicstartMeasureAndStream(Forbidden #18)2. Bug fix: Layer E race in
chrome-ext-spa-allowlistFile:
apps/website/lib/backend-detector.tsRoot cause: SW's
announceToAllTabs()fires during page load (tabs.onUpdated→ status=complete), pushing 2 announce messages towindow. The SPA's listener was lazy-installed only on firstdetectBackendcall, which happens AFTER React mounts. By the time the listener registered, the announce events had already dispatched and were lost forever.Diagnosis evidence: Playwright's
addInitScript(which installs listeners before any page script runs) captured both announces with correct payload:[{"ts":1779424823970,"data":{"source":"ohmyperf-extension","type":"ohmyperf/announce","extensionId":"emhengbdmoiimmchfnmpajaifkgobdfd","version":"0.2.0"}}, ... ]Fix: Install listener at module load time (eager). Listener now exists before React mounts, catches both announces.
3. Bug fix: Layer F missing useEffect in
chrome-ext-spa-allowlistFile:
apps/website/app/measure/page.tsxRoot cause:
/measurepage only calleddetectBackendinsidehandleMeasure(clicked). Initial render always sawbackend.kind === 'none'→ rendered NoBackendGuide → user saw "No runner detected" even with extension installed and active. Clicking Measure would belatedly run detection.Fix: useEffect runs once on mount, calls detectBackend with AbortController, sets backend if detected. Cleanup on unmount aborts in-flight detection.
4. Registry updates
chrome-ext-spa-allowlistextended (Layers E + F added)installAnnounceListenerMUST be called at module top-level outside any functionapp/measure/page.tsxMUSTuseEffect(detectBackend, [])whenbackend.kind === 'none'app/measure/page.tsxNEW system:
extension-e2e-test-infra(4 layers)launchPersistentContext(NOTchromium.launch + newContext)--disable-extensions-exceptAND--load-extensionboth setOHMYPERF_E2E_HEADLESSenv var toggle for CI vs local5. HARNESS doc updates
multi-layerandhigh-risklanestest:e2e:extensionVerification (Evidence Receipts per HARNESS schema)
test:e2e:extension— exit 0, 5/5 PASS, 17.3stest:cross-cutting-allowlists— exit 0, 3/3 PASSvalidate:quick— exit 0, all cleanpnpm --filter @ohmyperf/extension-chrome typecheck✅pnpm --filter @ohmyperf/extension-chrome lint✅pnpm --filter @ohmyperf/website typecheck✅pnpm --filter @ohmyperf/website lint✅ (No ESLint warnings or errors)Multi-Layer Pre-Flight Block (per Forbidden #20)
Counts
chrome-ext-spa-allowlistlayers: 4 → 6Risk
Low risk — additive: new test harness, new ladder layer, registry extensions. The two SPA fixes are minimal:
Both fixes have a structural test now (
test:e2e:extensionwould catch any future regression).Out of scope (v0.3 follow-up)
test:e2e:extension— headed extension testing in GH Actions needsxvfb-run+ the Chromium download step is non-trivial. Tracked for v0.3.--headless=newwith extensions has known issues on Windows; defer.🤖 Generated by Sisyphus + dogfooded against the harness em wrote yesterday.