experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(harness): reusable Electron CDP debugging harness for chrome-extension flows

Adds tests/manual/, a Playwright-driven harness that launches Peek with an
isolated test profile, attaches webContents.debugger to every webContents
top-level windows AND embedded webviews, and dumps a structured trace
JSON plus grep-friendly .log to tmp/harness/.

The harness captures network, console, and chrome.runtime introspection
across every Electron frame. Playwright's page-level listeners can't see
the webview's separate webContents, but CDP per-webContents can. First
consumer is tests/manual/proton-auth.harness.ts; design accepts arbitrary
scenarios via tests/manual/<name>.harness.ts.

Stop signals besides the 3min HARNESS_DURATION_MS ceiling:
- Close the auth window in the Peek instance.
- yarn harness:stop -- touches tmp/harness-stop sentinel.

Don't Ctrl+C the terminal -- that kills before the dump.

Files added:
- tests/manual/harness/types.ts
- tests/manual/harness/env.ts
- tests/manual/harness/tracer.ts
- tests/manual/harness/popup.ts
- tests/manual/harness/instrument.ts
- tests/manual/proton-auth.harness.ts
- tests/manual/README.md
- tests/desktop/harness-smoke.spec.ts -- CI guard so the harness can't rot.

Files modified:
- playwright.config.ts -- adds a 'manual' project, testMatch *.harness.ts.
- package.json -- adds yarn harness and yarn harness:stop.

+1226
+32
@
··· 1 + feat(harness): reusable Electron CDP debugging harness for chrome-extension flows 2 + 3 + Adds tests/manual/, a Playwright-driven harness that launches Peek with an 4 + isolated test profile, attaches webContents.debugger to every webContents 5 + top-level windows AND embedded webviews, and dumps a structured trace 6 + JSON plus grep-friendly .log to tmp/harness/. 7 + 8 + The harness captures network, console, and chrome.runtime introspection 9 + across every Electron frame. Playwright's page-level listeners can't see 10 + the webview's separate webContents, but CDP per-webContents can. First 11 + consumer is tests/manual/proton-auth.harness.ts; design accepts arbitrary 12 + scenarios via tests/manual/<name>.harness.ts. 13 + 14 + Stop signals besides the 3min HARNESS_DURATION_MS ceiling: 15 + - Close the auth window in the Peek instance. 16 + - yarn harness:stop -- touches tmp/harness-stop sentinel. 17 + 18 + Don't Ctrl+C the terminal -- that kills before the dump. 19 + 20 + Files added: 21 + - tests/manual/harness/types.ts 22 + - tests/manual/harness/env.ts 23 + - tests/manual/harness/tracer.ts 24 + - tests/manual/harness/popup.ts 25 + - tests/manual/harness/instrument.ts 26 + - tests/manual/proton-auth.harness.ts 27 + - tests/manual/README.md 28 + - tests/desktop/harness-smoke.spec.ts -- CI guard so the harness can't rot. 29 + 30 + Files modified: 31 + - playwright.config.ts -- adds a 'manual' project, testMatch *.harness.ts. 32 + - package.json -- adds yarn harness and yarn harness:stop.
+3
package.json
··· 153 153 "test:grep": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=1 BACKEND=electron npx playwright test tests/desktop/ --grep \"$0\"'", 154 154 "test:electron:bg": "nohup yarn test:electron > /tmp/test-electron.log 2>&1 & disown; echo 'Tests running in background, see: /tmp/test-electron.log'", 155 155 "test:log": "tail -f /tmp/test-electron.log", 156 + "//-- Manual debugging harness (see tests/manual/README.md) --//": "", 157 + "harness": "./scripts/timed.sh sh -c 'yarn build && BACKEND=electron HEADLESS=0 npx playwright test --project=manual --workers=1 --headed'", 158 + "harness:stop": "mkdir -p tmp && touch tmp/harness-stop", 156 159 "//-- Misc (BACKEND=electron|tauri, defaults to electron) --//": "", 157 160 "kill": "[ \"${BACKEND:-}\" = \"tauri\" ] && yarn kill:tauri || yarn kill:electron", 158 161 "kill:electron": "pkill -f '/Users/dietrich/misc/peek/node_modules/.bin/electron' || true",
+11
playwright.config.ts
··· 63 63 // Extension tests use custom fixture that launches Chrome with extension 64 64 // Firefox not supported - use yarn extension:firefox for manual testing 65 65 }, 66 + { 67 + // Manual debugging harness — see tests/manual/README.md. 68 + // Not included in `yarn test:electron`; run via `yarn harness`. 69 + // Long timeout because runs are interactive (humans drive the UI 70 + // while the tracer records). Workers must be 1 (set via CLI in the 71 + // yarn script) so the human only has to deal with one window stack. 72 + name: 'manual', 73 + testMatch: /manual\/.*\.harness\.ts$/, 74 + fullyParallel: false, 75 + timeout: 600_000, 76 + }, 66 77 ], 67 78 68 79 // Test execution settings
+92
tests/desktop/harness-smoke.spec.ts
··· 1 + /** 2 + * Smoke test for the manual harness primitives. 3 + * 4 + * The manual harness (tests/manual/) is run interactively via `yarn harness` 5 + * and isn't part of `yarn test:electron`. That makes it easy for the harness 6 + * to silently rot — a refactor in DesktopApp / chrome-extensions / preloads 7 + * could break startTracer() and we'd only notice the next time we tried to 8 + * debug an extension issue. 9 + * 10 + * This spec exercises the harness primitives against a trivial scenario: 11 + * - startTracer() attaches CDP across all webContents, 12 + * - record() / attachToPage() / snapshotRuntime() work on the bg window, 13 + * - dump() produces a JSON file with at least the events we recorded. 14 + * 15 + * It does NOT drive any real extension popup — that's the harness's own job 16 + * and varies per scenario. We just confirm the plumbing. 17 + */ 18 + 19 + import { test, expect } from '../fixtures/desktop-app'; 20 + import path from 'path'; 21 + import fs from 'fs'; 22 + import { fileURLToPath } from 'url'; 23 + import { createPerDescribeApp } from '../helpers/test-app'; 24 + import { startTracer } from '../manual/harness/tracer'; 25 + 26 + const __filename = fileURLToPath(import.meta.url); 27 + const __dirname = path.dirname(__filename); 28 + 29 + test.describe('harness plumbing @desktop', () => { 30 + let app: any; 31 + let bgWindow: any; 32 + 33 + test.beforeAll(async () => { 34 + ({ app, bgWindow } = await createPerDescribeApp('harness-smoke')); 35 + }); 36 + 37 + test.afterAll(async () => { 38 + if (app) await app.close(); 39 + }); 40 + 41 + test('tracer attaches, records, and dumps a structured trace', async () => { 42 + const tmpDir = path.join(__dirname, '..', '..', 'tmp', 'harness-smoke'); 43 + 44 + const tracer = await startTracer({ 45 + app, 46 + scenarioName: 'smoke', 47 + tmpDir, 48 + profile: 'test-harness-smoke', 49 + backend: 'electron', 50 + redactedEnvKeys: [], 51 + }); 52 + 53 + tracer.attachToPage(bgWindow, 'bg'); 54 + tracer.record('note', 'harness', { msg: 'smoke start' }); 55 + 56 + // Force a deterministic console event in the bg page so we can assert it 57 + // appears in the trace via either the Playwright listener or CDP. 58 + await bgWindow.evaluate(() => console.log('peek-harness-smoke-marker', { v: 1 })); 59 + 60 + // Snapshot chrome.runtime in the bg page (peek://test/ — chrome may not 61 + // exist there, that's fine; the snapshot still records `hasChrome:false` 62 + // which we just check is recorded). 63 + await tracer.snapshotRuntime(bgWindow, 'bg').catch(() => { 64 + tracer.record('note', 'harness', { msg: 'snapshot-runtime threw — recorded only' }); 65 + }); 66 + 67 + // Brief wait so the CDP-side console event has time to round-trip. 68 + await new Promise((r) => setTimeout(r, 500)); 69 + 70 + const { jsonPath, logPath } = await tracer.dump({ ok: true }); 71 + 72 + expect(fs.existsSync(jsonPath)).toBe(true); 73 + expect(fs.existsSync(logPath)).toBe(true); 74 + 75 + const trace = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); 76 + expect(trace.scenario).toBe('smoke'); 77 + expect(Array.isArray(trace.events)).toBe(true); 78 + expect(trace.events.length).toBeGreaterThan(0); 79 + 80 + // Our recorded note must be present. 81 + const hasSmokeNote = trace.events.some( 82 + (e: any) => e.kind === 'note' && e?.data?.msg === 'smoke start', 83 + ); 84 + expect(hasSmokeNote).toBe(true); 85 + 86 + // The runtime snapshot we asked for should be present (or its failure note). 87 + const hasRuntime = trace.events.some( 88 + (e: any) => e.kind === 'runtime' || (e.kind === 'note' && /snapshot-runtime/.test(e?.data?.msg || '')), 89 + ); 90 + expect(hasRuntime).toBe(true); 91 + }); 92 + });
+152
tests/manual/README.md
··· 1 + # Manual debugging harness 2 + 3 + Reusable Electron + CDP harness for capturing what's happening inside Peek when 4 + chrome-extension flows misbehave. Drives the running app via Playwright's 5 + `_electron`, attaches `webContents.debugger` to every webContents (popup, 6 + host pages, embedded webviews, BG SWs), and dumps a structured trace to 7 + `tmp/harness/`. 8 + 9 + ## Run 10 + 11 + ```sh 12 + yarn harness # all scenarios 13 + yarn harness tests/manual/proton-auth.harness.ts # one scenario 14 + ``` 15 + 16 + `yarn harness` runs Playwright in `--headed --workers=1` against the `manual` 17 + project (defined in `playwright.config.ts`). It launches a fresh isolated 18 + profile (`test-harness-*`) so it never touches your dev/prod data. 19 + 20 + ## Ending a run early 21 + 22 + The harness sleeps for `HARNESS_DURATION_MS` (default 3 min) by default. To 23 + stop it as soon as you're done driving the UI, pick whichever is easiest: 24 + 25 + - **Close the Proton auth window** in the Peek instance (the one with the 26 + `account.proton.me` webview). The harness polls `isClosed()` every second 27 + and exits cleanly with a "auth window closed" reason. 28 + - **`yarn harness:stop`** from another terminal. Touches `tmp/harness-stop`, 29 + which the harness picks up on its next poll and exits. 30 + - **Wait it out** — the trace dumps when `HARNESS_DURATION_MS` elapses. 31 + 32 + In all three cases the trace is dumped to `tmp/harness/` before the test 33 + exits. Don't Ctrl+C the terminal — that kills Playwright before the dump. 34 + 35 + Note: Cmd+Q on the Peek window only works if you can reach the OS-level 36 + window (it doesn't if `yarn harness` was run from inside an integrated 37 + terminal where the keyboard shortcut goes to the editor instead). Closing 38 + the auth window with the mouse / window controls always works. 39 + 40 + Output: 41 + 42 + ``` 43 + tmp/harness/ 44 + <scenario>-<ISO>.jsonl # raw CDP messages (kept for forensics) 45 + <scenario>-<ISO>.json # canonical trace 46 + <scenario>-<ISO>.log # flat grep-friendly text 47 + ``` 48 + 49 + ## What the tracer captures 50 + 51 + For every webContents (top-level windows AND embedded webviews): 52 + 53 + - **Network** — `Network.requestWillBeSent`, `responseReceived`, 54 + `loadingFailed`. For 4xx/5xx responses, the body is fetched via 55 + `Network.getResponseBody` and recorded. 56 + - **Console** — page console output via Playwright's `page.on('console')` for 57 + top-level windows AND CDP `Runtime.consoleAPICalled` / `Log.entryAdded` for 58 + webviews. 59 + - **Page errors** — uncaught exceptions. 60 + - **Navigation** — `did-navigate` / `did-navigate-in-page` (for in-page 61 + history navigations like Proton's /authorize → /reauth → /auth-ext). 62 + - **chrome.runtime snapshots** — explicit `tracer.snapshotRuntime(page, 63 + label)` calls record `{ id, sendMessage, getURL, getManifest, 64 + onMessageExternal, keys }` so we can see what Electron exposed at that 65 + moment. Also supported on webviews via `{ fromWebview: '#content' }`. 66 + - **Scenario notes** — `tracer.record('note', source, { msg, ... })` for 67 + human-readable milestones. 68 + 69 + ## Adding a scenario 70 + 71 + 1. Create `tests/manual/<name>.harness.ts`. 72 + 2. Use the desktop-app fixture, start the tracer, drive the UI, dump. 73 + 3. Don't assert success on the diagnosis — the spec passes if the trace 74 + file was produced. Reading the trace is a human activity. 75 + 76 + Skeleton: 77 + 78 + ```ts 79 + import { test, expect } from '../fixtures/desktop-app'; 80 + import path from 'path'; 81 + import { startTracer } from './harness/tracer'; 82 + import { discoverPopup } from './harness/popup'; 83 + import { loadEnv, assertRequiredEnv } from './harness/env'; 84 + 85 + const REPO_ROOT = path.join(__dirname, '..', '..'); 86 + 87 + test.describe('<scenario-name> @manual', () => { 88 + test.setTimeout(300_000); 89 + 90 + test('drive the flow while tracer records', async ({ desktopApp }) => { 91 + const bg = await desktopApp.getBackgroundWindow(); 92 + const env = loadEnv('.env.<scenario>.local', REPO_ROOT); 93 + // assertRequiredEnv(env.env, [...], '<scenario-name>'); 94 + 95 + const tracer = await startTracer({ 96 + app: desktopApp, 97 + scenarioName: '<scenario-name>', 98 + tmpDir: path.join(REPO_ROOT, 'tmp', 'harness'), 99 + profile: 'test-harness-<scenario>', 100 + backend: desktopApp.backend, 101 + redactedEnvKeys: env.loadedKeys, 102 + }); 103 + 104 + let result; 105 + try { 106 + // Drive the UI here. Use discoverPopup() for extension popups. 107 + // Use tracer.snapshotRuntime(page, label) at interesting moments. 108 + // Use tracer.record('note', 'harness', { msg: '...' }) for milestones. 109 + result = { ok: true }; 110 + } catch (e: any) { 111 + tracer.record('note', 'harness', { msg: 'scenario error', error: String(e?.message || e) }); 112 + result = { ok: false, reason: String(e?.message || e) }; 113 + } 114 + 115 + const out = await tracer.dump(result); 116 + console.log(`[harness] trace: ${out.jsonPath}`); 117 + expect(out.jsonPath).toBeTruthy(); 118 + }); 119 + }); 120 + ``` 121 + 122 + ## Modes 123 + 124 + Most scenarios should support an interactive mode (human drives the UI) and 125 + ideally an autodrive mode (creds from `.env.<scenario>.local`, scripted UI 126 + steps). Start with interactive — autodrive selectors are easier to write 127 + once the interactive trace has shown the actual DOM. 128 + 129 + Common envs: 130 + 131 + - `HARNESS_MODE=interactive|autodrive` 132 + - `HARNESS_DURATION_MS=180000` (interactive wait window) 133 + 134 + ## Why CDP, not Playwright network listeners 135 + 136 + Playwright's `page.on('request')` only fires for the top-level `Page`'s 137 + webContents. Peek's chrome-extension flows happen inside `<webview>` 138 + elements, which are **separate webContents** that Playwright doesn't see. 139 + `webContents.debugger` (CDP) is per-webContents and can be attached to all 140 + of them from the main process. That's the only way to capture the network 141 + activity inside an extension's auth window. 142 + 143 + ## Why interactive by default 144 + 145 + `yarn harness` opens real windows (`--headed`) with a generous timeout and 146 + sleeps in a polling loop while you drive the UI by hand. The tracer captures 147 + everything in the background. When you're done — or when you hit the time 148 + limit — the trace is dumped to `tmp/harness/` and you read it. 149 + 150 + This is the opposite of a normal Playwright spec, which runs deterministically 151 + and asserts behavior. The harness's only assertion is "we produced a trace 152 + file" — the diagnosis is a human reading that file.
+56
tests/manual/harness/env.ts
··· 1 + /** 2 + * Tiny .env file loader. 3 + * 4 + * No dotenv dependency — this parses just what we need: KEY=VALUE lines, 5 + * comments starting with '#', optional surrounding quotes. Multi-line and 6 + * variable interpolation are unsupported on purpose. 7 + */ 8 + 9 + import fs from 'fs'; 10 + import path from 'path'; 11 + 12 + export interface LoadEnvResult { 13 + env: Record<string, string>; 14 + /** Names of keys that were loaded — useful for redaction in trace metadata. */ 15 + loadedKeys: string[]; 16 + /** Path that was actually read, or null if the file didn't exist. */ 17 + source: string | null; 18 + } 19 + 20 + export function loadEnv(envFile: string | undefined, repoRoot: string): LoadEnvResult { 21 + if (!envFile) return { env: {}, loadedKeys: [], source: null }; 22 + 23 + const abs = path.isAbsolute(envFile) ? envFile : path.join(repoRoot, envFile); 24 + if (!fs.existsSync(abs)) return { env: {}, loadedKeys: [], source: null }; 25 + 26 + const raw = fs.readFileSync(abs, 'utf8'); 27 + const env: Record<string, string> = {}; 28 + const loadedKeys: string[] = []; 29 + 30 + for (const line of raw.split(/\r?\n/)) { 31 + const trimmed = line.trim(); 32 + if (!trimmed || trimmed.startsWith('#')) continue; 33 + const eq = trimmed.indexOf('='); 34 + if (eq === -1) continue; 35 + const key = trimmed.slice(0, eq).trim(); 36 + let value = trimmed.slice(eq + 1).trim(); 37 + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { 38 + value = value.slice(1, -1); 39 + } 40 + env[key] = value; 41 + loadedKeys.push(key); 42 + } 43 + 44 + return { env, loadedKeys, source: abs }; 45 + } 46 + 47 + export function assertRequiredEnv(env: Record<string, string>, required: string[] | undefined, scenarioName: string): void { 48 + if (!required || required.length === 0) return; 49 + const missing = required.filter((k) => !env[k] || env[k].length === 0); 50 + if (missing.length > 0) { 51 + throw new Error( 52 + `[harness:${scenarioName}] missing required env: ${missing.join(', ')}. ` + 53 + `Populate them in the scenario's envFile (typically .env.<scenario>.local at repo root).`, 54 + ); 55 + } 56 + }
+167
tests/manual/harness/instrument.ts
··· 1 + /** 2 + * Page-side instrumentation for harness scenarios. 3 + * 4 + * Injected via CDP `Page.addScriptToEvaluateOnNewDocument` so it runs in main 5 + * world before any page script on every new document. Useful when the page 6 + * under test is silently catching errors (Sentry, try/catch around feature 7 + * detection, etc.) — we wrap the obvious channels so the swallowed signals 8 + * surface as console output that the tracer captures. 9 + * 10 + * What it does (all logs prefixed `[peek-instrument]` for grep): 11 + * - Proxies `window.chrome.runtime` so every property access and method 12 + * call is logged with the property name and (for calls) arguments. 13 + * - Adds `window.addEventListener('error', ...)` and 14 + * `'unhandledrejection'` to capture trapped errors. 15 + * - Wraps `console.error` / `console.warn` to also log via `console.log` 16 + * under the instrument prefix (Sentry sometimes monkey-patches these to 17 + * swallow them; logging via console.log preserves a copy). 18 + * - Wraps `window.fetch` to log URLs and statuses (network is already 19 + * captured by CDP, but having a page-side fetch log lets us correlate 20 + * calls back to the JS callsite via stacks). 21 + * 22 + * Designed to be safe to inject on every page (no-op if chrome / fetch / etc. 23 + * aren't there). 24 + */ 25 + 26 + export function getInstrumentationSource(): string { 27 + return `(function(){ 28 + if (window.__peekInstrumented) return; 29 + window.__peekInstrumented = true; 30 + 31 + var TAG = '[peek-instrument]'; 32 + var origLog = console.log.bind(console); 33 + function logTag() { 34 + var args = [TAG].concat(Array.prototype.slice.call(arguments)); 35 + try { origLog.apply(console, args); } catch (_) {} 36 + } 37 + 38 + // ---- chrome.runtime Proxy (with re-wrap loop) ---- 39 + // Race note: this script registers via CDP addScriptToEvaluateOnNewDocument 40 + // and runs early in main world. Our runtime-external polyfill ALSO runs 41 + // early (preload + addScriptToEvaluateOnNewDocument) and replaces 42 + // chrome.runtime via defineProperty. Whichever runs second wins. We tag 43 + // any Proxy we install with __peekRuntimeProxyTagged on the parent 44 + // window.chrome (Proxy itself can't carry custom marker reliably) and 45 + // re-wrap on a few microtask/timeout ticks so we end up wrapping the 46 + // polyfill (or native, whichever survives). 47 + var seen = {}; 48 + function makeRuntimeProxy(rt) { 49 + return new Proxy(rt, { 50 + get: function(target, prop, receiver) { 51 + var key = String(prop); 52 + if (!seen[key]) { seen[key] = 0; } 53 + seen[key]++; 54 + if (seen[key] === 1 || seen[key] % 50 === 0) { 55 + logTag('chrome.runtime GET', key, '#' + seen[key]); 56 + } 57 + var v = Reflect.get(target, prop, receiver); 58 + if (typeof v === 'function') { 59 + return function() { 60 + var argv = Array.prototype.slice.call(arguments); 61 + var argSummary; 62 + try { argSummary = JSON.stringify(argv).slice(0, 300); } 63 + catch (_) { argSummary = '(unserializable args)'; } 64 + logTag('chrome.runtime CALL', key, argSummary); 65 + try { 66 + var r = v.apply(target, argv); 67 + if (r && typeof r.then === 'function') { 68 + r.then(function(rv){ 69 + var rs; 70 + try { rs = JSON.stringify(rv).slice(0, 300); } catch (_) { rs = '(unserializable)'; } 71 + logTag('chrome.runtime RESOLVE', key, rs); 72 + }, function(re){ 73 + logTag('chrome.runtime REJECT', key, (re && re.message) || String(re)); 74 + }); 75 + } 76 + return r; 77 + } catch (e) { 78 + logTag('chrome.runtime THREW', key, (e && e.message) || String(e)); 79 + throw e; 80 + } 81 + }; 82 + } 83 + return v; 84 + }, 85 + }); 86 + } 87 + function installProxy(reason) { 88 + try { 89 + if (!window.chrome || !window.chrome.runtime) return false; 90 + var c = window.chrome; 91 + // window.chrome.__peekProxiedRuntimeRef points at the underlying 92 + // runtime object we last wrapped. If it matches the current value, 93 + // chrome.runtime IS our proxy already — skip. 94 + if (c.__peekProxiedRuntimeRef === c.runtime) return false; 95 + var underlying = c.runtime; 96 + var proxy = makeRuntimeProxy(underlying); 97 + Object.defineProperty(c, 'runtime', { 98 + value: proxy, writable: true, configurable: true, enumerable: true, 99 + }); 100 + c.__peekProxiedRuntimeRef = proxy; 101 + logTag('chrome.runtime proxy installed', reason || '', 'on', location.href); 102 + return true; 103 + } catch (e) { 104 + logTag('chrome.runtime proxy install FAILED', (e && e.message) || String(e)); 105 + return false; 106 + } 107 + } 108 + installProxy('immediate'); 109 + queueMicrotask(function(){ installProxy('microtask'); }); 110 + setTimeout(function(){ installProxy('t+0'); }, 0); 111 + setTimeout(function(){ installProxy('t+50'); }, 50); 112 + setTimeout(function(){ installProxy('t+250'); }, 250); 113 + setTimeout(function(){ installProxy('t+1000'); }, 1000); 114 + 115 + // ---- Error / rejection listeners ---- 116 + window.addEventListener('error', function(ev) { 117 + logTag('window.error', 118 + (ev.error && ev.error.message) || ev.message, 119 + ev.filename + ':' + ev.lineno + ':' + ev.colno); 120 + if (ev.error && ev.error.stack) { 121 + logTag('window.error stack', String(ev.error.stack).slice(0, 1000)); 122 + } 123 + }, true); 124 + window.addEventListener('unhandledrejection', function(ev) { 125 + var r = ev.reason; 126 + logTag('unhandledrejection', 127 + (r && r.message) || String(r), 128 + (r && r.stack) ? String(r.stack).slice(0, 1000) : '(no stack)'); 129 + }, true); 130 + 131 + // ---- console.error / console.warn wrapping ---- 132 + var origErr = console.error.bind(console); 133 + var origWarn = console.warn.bind(console); 134 + console.error = function() { 135 + var argv = Array.prototype.slice.call(arguments); 136 + try { logTag.apply(null, ['console.error'].concat(argv)); } catch (_) {} 137 + try { origErr.apply(console, argv); } catch (_) {} 138 + }; 139 + console.warn = function() { 140 + var argv = Array.prototype.slice.call(arguments); 141 + try { logTag.apply(null, ['console.warn'].concat(argv)); } catch (_) {} 142 + try { origWarn.apply(console, argv); } catch (_) {} 143 + }; 144 + 145 + // ---- fetch wrapping (best-effort; CDP already captures responses) ---- 146 + if (typeof window.fetch === 'function') { 147 + var origFetch = window.fetch.bind(window); 148 + window.fetch = function(input, init) { 149 + var url = (typeof input === 'string') ? input : (input && input.url) || ''; 150 + if (url.indexOf('account.proton.me') !== -1 || url.indexOf('proton.me/api') !== -1) { 151 + logTag('fetch CALL', (init && init.method) || 'GET', url); 152 + } 153 + return origFetch(input, init).then(function(res){ 154 + if (url.indexOf('account.proton.me') !== -1 || url.indexOf('proton.me/api') !== -1) { 155 + logTag('fetch RESP', res.status, url); 156 + } 157 + return res; 158 + }, function(err){ 159 + logTag('fetch FAIL', (err && err.message) || String(err), url); 160 + throw err; 161 + }); 162 + }; 163 + } 164 + 165 + logTag('instrumentation installed on', location.href); 166 + })();`; 167 + }
+63
tests/manual/harness/popup.ts
··· 1 + /** 2 + * Discover and open a chrome-extension popup window in the running app. 3 + * 4 + * Pattern lifted verbatim from tests/desktop/proton-pass-permissions.spec.ts: 5 + * bgWindow → app.chromeExtensions.getUiEntries() 6 + * → window.app.window.open(popupUrl, ...) 7 + * → app.getWindow('popup.html') 8 + */ 9 + 10 + import { Page } from '@playwright/test'; 11 + import { DesktopApp } from '../../fixtures/desktop-app'; 12 + 13 + export interface UiEntry { 14 + extensionId: string; 15 + extensionName: string; 16 + type: string; // 'popup' | 'options' | ... 17 + url: string; 18 + } 19 + 20 + export async function listUiEntries(bgWindow: Page): Promise<UiEntry[]> { 21 + return bgWindow.evaluate(async () => { 22 + const r = await (window as any).app.chromeExtensions.getUiEntries(); 23 + return r.data as UiEntry[]; 24 + }); 25 + } 26 + 27 + export async function discoverPopup( 28 + app: DesktopApp, 29 + bgWindow: Page, 30 + extensionNameSubstring: string, 31 + opts: { width?: number; height?: number; key?: string; timeout?: number } = {}, 32 + ): Promise<{ popup: Page; entry: UiEntry }> { 33 + const entries = await listUiEntries(bgWindow); 34 + const needle = extensionNameSubstring.toLowerCase(); 35 + const entry = entries.find((e) => e.type === 'popup' && e.extensionName.toLowerCase().includes(needle)); 36 + if (!entry) { 37 + const known = entries.filter((e) => e.type === 'popup').map((e) => e.extensionName).join(', '); 38 + throw new Error( 39 + `[harness] popup for extension matching "${extensionNameSubstring}" not found. ` + 40 + `Known popup extensions: ${known || '<none>'}.`, 41 + ); 42 + } 43 + 44 + const openResult = await bgWindow.evaluate( 45 + async (args: { url: string; width: number; height: number; key: string }) => { 46 + return await (window as any).app.window.open(args.url, { 47 + width: args.width, height: args.height, key: args.key, 48 + }); 49 + }, 50 + { 51 + url: entry.url, 52 + width: opts.width ?? 400, 53 + height: opts.height ?? 600, 54 + key: opts.key ?? `harness-${entry.extensionId}-popup`, 55 + }, 56 + ); 57 + if (!(openResult as any)?.success) { 58 + throw new Error(`[harness] window.open failed for popup ${entry.url}: ${JSON.stringify(openResult)}`); 59 + } 60 + 61 + const popup = await app.getWindow('popup.html', opts.timeout ?? 15000); 62 + return { popup, entry }; 63 + }
+395
tests/manual/harness/tracer.ts
··· 1 + /** 2 + * CDP-based tracer for the harness. 3 + * 4 + * Why CDP and not Playwright's page.on('request')? 5 + * - Playwright's network events fire only for the top-level Page's 6 + * webContents. Peek's chrome-extension flows happen inside <webview> 7 + * elements, which are *separate* webContents that Playwright doesn't see 8 + * as Pages. 9 + * - Electron's `webContents.debugger` (CDP) is per-webContents and can be 10 + * attached from the main process to every existing and newly-created 11 + * webContents — popup, host page, embedded webview, extension service 12 + * worker. That covers everything we care about. 13 + * 14 + * Architecture: 15 + * 1. start(): 16 + * - calls electronApp.evaluate(...) to install a main-process tap that 17 + * attaches `webContents.debugger` to every existing webContents and 18 + * listens for `app.on('web-contents-created')` to catch new ones. 19 + * - the tap appends raw CDP messages + per-wc metadata to a JSONL file 20 + * in tmp/harness/. 21 + * 2. While the scenario runs, the harness collects in-memory events from 22 + * the Playwright side too (page-level console, pageerror, scenario 23 + * milestones via record()). 24 + * 3. dump(): 25 + * - calls electronApp.evaluate(...) again to detach debuggers, 26 + * - reads the JSONL file, 27 + * - translates CDP events to canonical TraceEvent objects, 28 + * - merges with in-memory events, sorts by t, 29 + * - writes a final structured JSON + a flat .log companion to tmp/harness/. 30 + * 31 + * The JSONL is left on disk (intentionally) — it's the raw record, useful 32 + * if the post-processing has a bug. 33 + */ 34 + 35 + import fs from 'fs'; 36 + import path from 'path'; 37 + import { Page } from '@playwright/test'; 38 + import { DesktopApp } from '../../fixtures/desktop-app'; 39 + import { HarnessTrace, Tracer, TraceEvent, RuntimeSnapshot } from './types'; 40 + 41 + interface TracerOpts { 42 + app: DesktopApp; 43 + scenarioName: string; 44 + tmpDir: string; 45 + profile: string; 46 + backend: string; 47 + redactedEnvKeys: string[]; 48 + } 49 + 50 + interface RawCdpLine { 51 + t: number; 52 + wcId: number; 53 + kind: string; 54 + method?: string; 55 + params?: any; 56 + info?: { id: number; type: string; url: string }; 57 + url?: string; 58 + error?: string; 59 + body?: string; 60 + base64Encoded?: boolean; 61 + requestId?: string; 62 + } 63 + 64 + export async function startTracer(opts: TracerOpts): Promise<Tracer> { 65 + fs.mkdirSync(opts.tmpDir, { recursive: true }); 66 + const ts = new Date().toISOString().replace(/[:.]/g, '-'); 67 + const baseName = `${opts.scenarioName}-${ts}`; 68 + const jsonlPath = path.join(opts.tmpDir, `${baseName}.jsonl`); 69 + const jsonPath = path.join(opts.tmpDir, `${baseName}.json`); 70 + const logPath = path.join(opts.tmpDir, `${baseName}.log`); 71 + 72 + const startedAt = Date.now(); 73 + const startedAtIso = new Date(startedAt).toISOString(); 74 + const inMemory: TraceEvent[] = []; 75 + const wcLabels = new Map<number, string>(); 76 + 77 + const record = (kind: TraceEvent['kind'], source: string, data: any) => { 78 + inMemory.push({ t: Date.now() - startedAt, kind, source, data }); 79 + }; 80 + 81 + if (!opts.app.evaluateMain) { 82 + throw new Error('[harness] tracer requires Electron backend (DesktopApp.evaluateMain).'); 83 + } 84 + 85 + // Main-process tap: attaches CDP debugger to all current + future webContents 86 + // and buffers events on a globalThis array. Writing to fs here is not 87 + // possible — `electronApp.evaluate(fn)` serializes `fn` to source and evals 88 + // it in the main process, where neither `require` (ESM main) nor dynamic 89 + // `import()` (no import callback registered for eval'd code) work. So we 90 + // buffer in-process and the test side drains via a follow-up evaluateMain. 91 + await opts.app.evaluateMain(async ({ app, webContents }) => { 92 + const g: any = globalThis as any; 93 + if (g.__peekHarnessBuffer) return; // already attached this run 94 + const buf: any[] = []; 95 + g.__peekHarnessBuffer = buf; 96 + 97 + const pendingStatuses = new Map<number, Map<string, number>>(); 98 + function statusMap(wcId: number): Map<string, number> { 99 + let m = pendingStatuses.get(wcId); 100 + if (!m) { m = new Map(); pendingStatuses.set(wcId, m); } 101 + return m; 102 + } 103 + 104 + function attach(wc: any) { 105 + if (wc.isDestroyed()) return; 106 + if (wc.__peekHarnessAttached) return; 107 + wc.__peekHarnessAttached = true; 108 + try { 109 + if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); 110 + } catch (e: any) { 111 + buf.push({ t: Date.now(), wcId: wc.id, kind: 'attach-error', error: String(e?.message || e) }); 112 + return; 113 + } 114 + const info = { id: wc.id, type: wc.getType(), url: wc.getURL() }; 115 + buf.push({ t: Date.now(), wcId: wc.id, kind: 'attached', info }); 116 + 117 + wc.debugger.sendCommand('Network.enable').catch(() => {}); 118 + wc.debugger.sendCommand('Page.enable').catch(() => {}); 119 + wc.debugger.sendCommand('Runtime.enable').catch(() => {}); 120 + wc.debugger.sendCommand('Log.enable').catch(() => {}); 121 + 122 + wc.debugger.on('message', async (_event: any, method: string, params: any) => { 123 + buf.push({ t: Date.now(), wcId: wc.id, kind: 'cdp', method, params }); 124 + if (method === 'Network.responseReceived') { 125 + if (params?.response?.status != null) statusMap(wc.id).set(params.requestId, params.response.status); 126 + } 127 + if (method === 'Network.loadingFinished') { 128 + const reqId = params.requestId; 129 + const status = statusMap(wc.id).get(reqId); 130 + if (status != null && status >= 400) { 131 + try { 132 + const body = await wc.debugger.sendCommand('Network.getResponseBody', { requestId: reqId }); 133 + buf.push({ t: Date.now(), wcId: wc.id, kind: 'response-body', requestId: reqId, body: body.body, base64Encoded: body.base64Encoded }); 134 + } catch (e: any) { 135 + buf.push({ t: Date.now(), wcId: wc.id, kind: 'response-body-error', requestId: reqId, error: String(e?.message || e) }); 136 + } 137 + } 138 + statusMap(wc.id).delete(reqId); 139 + } 140 + }); 141 + 142 + wc.on('did-navigate', (_e: any, url: string) => buf.push({ t: Date.now(), wcId: wc.id, kind: 'did-navigate', url })); 143 + wc.on('did-navigate-in-page', (_e: any, url: string) => buf.push({ t: Date.now(), wcId: wc.id, kind: 'did-navigate-in-page', url })); 144 + wc.on('destroyed', () => buf.push({ t: Date.now(), wcId: wc.id, kind: 'destroyed' })); 145 + } 146 + 147 + webContents.getAllWebContents().forEach(attach); 148 + app.on('web-contents-created', (_e: any, wc: any) => attach(wc)); 149 + }); 150 + 151 + // ---- Tracer object ---- 152 + const tracer: Tracer = { 153 + attachToPage(page: Page, sourceLabel: string) { 154 + page.on('console', (msg) => { 155 + record('console', sourceLabel, { level: msg.type(), text: msg.text(), location: msg.location() }); 156 + }); 157 + page.on('pageerror', (err) => { 158 + record('pageerror', sourceLabel, { message: err.message, stack: err.stack }); 159 + }); 160 + page.on('framenavigated', (frame) => { 161 + if (frame === page.mainFrame()) { 162 + record('navigation', sourceLabel, { url: frame.url(), source: 'playwright-page' }); 163 + } 164 + }); 165 + }, 166 + 167 + async snapshotRuntime(page: Page, sourceLabel: string, snapOpts) { 168 + const introspector = `(function() { 169 + const c = (globalThis).chrome; 170 + const b = (globalThis).browser; 171 + const r = c && c.runtime; 172 + return { 173 + hasChrome: !!c, 174 + hasBrowser: !!b, 175 + id: r ? (r.id || null) : null, 176 + sendMessage: r ? (typeof r.sendMessage) : 'undefined', 177 + getURL: r ? (typeof r.getURL) : 'undefined', 178 + getManifest: r ? (typeof r.getManifest) : 'undefined', 179 + onMessageExternal: r ? (typeof r.onMessageExternal) : 'undefined', 180 + keys: r ? Object.keys(r) : [] 181 + }; 182 + })()`; 183 + 184 + let snapshot: RuntimeSnapshot; 185 + if (snapOpts?.fromWebview) { 186 + // Drive a webview element inside the host page. 187 + snapshot = await page.evaluate(async ({ selector, src }) => { 188 + const wv = document.querySelector(selector) as any; 189 + if (!wv || typeof wv.executeJavaScript !== 'function') { 190 + return null; 191 + } 192 + return await wv.executeJavaScript(src); 193 + }, { selector: snapOpts.fromWebview, src: introspector }) as RuntimeSnapshot; 194 + } else { 195 + snapshot = await page.evaluate(introspector) as RuntimeSnapshot; 196 + } 197 + record('runtime', sourceLabel, snapshot); 198 + }, 199 + 200 + record, 201 + 202 + async dump(result) { 203 + const endedAt = Date.now(); 204 + const endedAtIso = new Date(endedAt).toISOString(); 205 + 206 + // Drain the main-process buffer and detach debuggers in one trip. 207 + let mainEvents: RawCdpLine[] = []; 208 + try { 209 + mainEvents = await opts.app.evaluateMain!(({ webContents }) => { 210 + const g: any = globalThis as any; 211 + const drained = (g.__peekHarnessBuffer || []).slice(); 212 + if (g.__peekHarnessBuffer) g.__peekHarnessBuffer.length = 0; 213 + for (const wc of webContents.getAllWebContents()) { 214 + try { if (!wc.isDestroyed() && wc.debugger.isAttached()) wc.debugger.detach(); } catch {} 215 + } 216 + return drained; 217 + }) as RawCdpLine[]; 218 + } catch { /* app may already be closing */ } 219 + 220 + // Persist the raw drain as a forensic record. 221 + try { 222 + fs.writeFileSync(jsonlPath, mainEvents.map((e) => JSON.stringify(e)).join('\n') + (mainEvents.length ? '\n' : '')); 223 + } catch {} 224 + 225 + // Build wcId → label map from "attached" events; subsequent events use it. 226 + for (const evt of mainEvents) { 227 + if (evt.kind === 'attached' && evt.info) { 228 + const label = `${evt.info.type}#${evt.info.id}${evt.info.url ? ` ${truncateUrl(evt.info.url)}` : ''}`; 229 + wcLabels.set(evt.wcId, label); 230 + } 231 + } 232 + 233 + // Translate CDP lines to canonical TraceEvents and merge. 234 + const cdpEvents: TraceEvent[] = []; 235 + for (const evt of mainEvents) { 236 + const t = evt.t - startedAt; 237 + const source = wcLabels.get(evt.wcId) || `wc#${evt.wcId}`; 238 + const tx = translateCdpLine(evt, t, source); 239 + if (tx) cdpEvents.push(...tx); 240 + } 241 + 242 + const events = [...inMemory, ...cdpEvents].sort((a, b) => a.t - b.t); 243 + 244 + const trace: HarnessTrace = { 245 + scenario: opts.scenarioName, 246 + startedAt: startedAtIso, 247 + endedAt: endedAtIso, 248 + result, 249 + env: { profile: opts.profile, backend: opts.backend, redactedEnvKeys: opts.redactedEnvKeys }, 250 + events, 251 + }; 252 + 253 + fs.writeFileSync(jsonPath, JSON.stringify(trace, null, 2)); 254 + fs.writeFileSync(logPath, renderLogLines(trace)); 255 + 256 + return { jsonPath, logPath }; 257 + }, 258 + }; 259 + 260 + return tracer; 261 + } 262 + 263 + function truncateUrl(url: string): string { 264 + if (url.length <= 80) return url; 265 + return url.slice(0, 77) + '...'; 266 + } 267 + 268 + function translateCdpLine(evt: RawCdpLine, t: number, source: string): TraceEvent[] | null { 269 + if (evt.kind === 'attached') { 270 + return [{ t, kind: 'note', source, data: { msg: 'cdp-attached', info: evt.info } }]; 271 + } 272 + if (evt.kind === 'attach-error') { 273 + return [{ t, kind: 'note', source, data: { msg: 'cdp-attach-error', error: evt.error } }]; 274 + } 275 + if (evt.kind === 'destroyed') { 276 + return [{ t, kind: 'note', source, data: { msg: 'webcontents-destroyed' } }]; 277 + } 278 + if (evt.kind === 'did-navigate' || evt.kind === 'did-navigate-in-page') { 279 + return [{ t, kind: 'navigation', source, data: { url: evt.url, source: evt.kind } }]; 280 + } 281 + if (evt.kind === 'response-body') { 282 + return [{ t, kind: 'response-body', source, data: { requestId: evt.requestId, body: evt.body, base64Encoded: evt.base64Encoded } }]; 283 + } 284 + if (evt.kind === 'response-body-error') { 285 + return [{ t, kind: 'note', source, data: { msg: 'response-body-error', requestId: evt.requestId, error: evt.error } }]; 286 + } 287 + if (evt.kind !== 'cdp' || !evt.method) return null; 288 + 289 + const p = evt.params || {}; 290 + switch (evt.method) { 291 + case 'Network.requestWillBeSent': 292 + return [{ 293 + t, kind: 'request', source, 294 + data: { 295 + requestId: p.requestId, 296 + method: p.request?.method, 297 + url: p.request?.url, 298 + resourceType: p.type, 299 + headers: p.request?.headers, 300 + postData: p.request?.postData, 301 + documentURL: p.documentURL, 302 + frameId: p.frameId, 303 + }, 304 + }]; 305 + case 'Network.responseReceived': 306 + return [{ 307 + t, kind: 'response', source, 308 + data: { 309 + requestId: p.requestId, 310 + status: p.response?.status, 311 + statusText: p.response?.statusText, 312 + url: p.response?.url, 313 + mimeType: p.response?.mimeType, 314 + headers: p.response?.headers, 315 + fromCache: p.response?.fromDiskCache || p.response?.fromServiceWorker, 316 + remoteIPAddress: p.response?.remoteIPAddress, 317 + }, 318 + }]; 319 + case 'Network.loadingFailed': 320 + return [{ 321 + t, kind: 'request-failed', source, 322 + data: { requestId: p.requestId, errorText: p.errorText, canceled: p.canceled, type: p.type }, 323 + }]; 324 + case 'Page.frameNavigated': 325 + return [{ 326 + t, kind: 'navigation', source, 327 + data: { url: p.frame?.url, frameId: p.frame?.id, source: 'cdp-page' }, 328 + }]; 329 + case 'Runtime.consoleAPICalled': 330 + return [{ 331 + t, kind: 'console', source, 332 + data: { level: p.type, args: (p.args || []).map((a: any) => a.value ?? a.description ?? a.preview), stackTrace: p.stackTrace }, 333 + }]; 334 + case 'Runtime.exceptionThrown': 335 + return [{ 336 + t, kind: 'pageerror', source, 337 + data: { message: p.exceptionDetails?.text, exception: p.exceptionDetails?.exception?.description, stackTrace: p.exceptionDetails?.stackTrace }, 338 + }]; 339 + case 'Log.entryAdded': 340 + return [{ 341 + t, kind: 'console', source, 342 + data: { level: p.entry?.level, text: p.entry?.text, source: p.entry?.source, url: p.entry?.url }, 343 + }]; 344 + default: 345 + return null; 346 + } 347 + } 348 + 349 + function renderLogLines(trace: HarnessTrace): string { 350 + const out: string[] = []; 351 + out.push(`# harness trace: ${trace.scenario}`); 352 + out.push(`# started: ${trace.startedAt}`); 353 + out.push(`# ended: ${trace.endedAt}`); 354 + out.push(`# result: ${JSON.stringify(trace.result)}`); 355 + out.push(`# profile: ${trace.env.profile} (${trace.env.backend})`); 356 + out.push(`# events: ${trace.events.length}`); 357 + out.push(''); 358 + for (const evt of trace.events) { 359 + out.push(formatLogLine(evt)); 360 + } 361 + return out.join('\n') + '\n'; 362 + } 363 + 364 + function formatLogLine(evt: TraceEvent): string { 365 + const t = `[t=${String(evt.t).padStart(6, ' ')}ms]`; 366 + const src = evt.source.padEnd(28, ' ').slice(0, 28); 367 + switch (evt.kind) { 368 + case 'request': 369 + return `${t} ${src} REQUEST ${evt.data.method || '?'} ${evt.data.url || ''}`; 370 + case 'response': 371 + return `${t} ${src} RESPONSE ${evt.data.status || '?'} ${evt.data.url || ''}`; 372 + case 'response-body': 373 + return `${t} ${src} BODY ${evt.data.requestId} ${shortBody(evt.data)}`; 374 + case 'request-failed': 375 + return `${t} ${src} FAILED ${evt.data.errorText || ''} (req ${evt.data.requestId})`; 376 + case 'navigation': 377 + return `${t} ${src} NAV ${evt.data.url || ''}`; 378 + case 'console': 379 + return `${t} ${src} CONSOLE [${evt.data.level || '?'}] ${(evt.data.text || JSON.stringify(evt.data.args || [])).slice(0, 200)}`; 380 + case 'pageerror': 381 + return `${t} ${src} ERROR ${(evt.data.message || '').slice(0, 200)}`; 382 + case 'runtime': 383 + return `${t} ${src} RUNTIME id=${evt.data.id || 'null'} sendMessage=${evt.data.sendMessage} keys=[${(evt.data.keys || []).join(',')}]`; 384 + case 'note': 385 + return `${t} ${src} NOTE ${JSON.stringify(evt.data).slice(0, 200)}`; 386 + default: 387 + return `${t} ${src} ${evt.kind} ${JSON.stringify(evt.data).slice(0, 200)}`; 388 + } 389 + } 390 + 391 + function shortBody(data: any): string { 392 + if (!data?.body) return '<empty>'; 393 + if (data.base64Encoded) return `<base64 ${data.body.length}b>`; 394 + return data.body.slice(0, 200).replace(/\s+/g, ' '); 395 + }
+52
tests/manual/harness/types.ts
··· 1 + /** 2 + * Shared types for the Electron chrome-extension debugging harness. 3 + * 4 + * See tests/manual/harness/README.md for usage. 5 + */ 6 + 7 + import { ElectronApplication, Page } from '@playwright/test'; 8 + 9 + export interface TraceEvent { 10 + /** ms since harness start */ 11 + t: number; 12 + kind: 'console' | 'pageerror' | 'request' | 'response' | 'response-body' | 'request-failed' | 'navigation' | 'runtime' | 'note'; 13 + /** human label for the source page/webContents (e.g. "popup", "auth-window", "auth-window:webview") */ 14 + source: string; 15 + /** kind-specific payload */ 16 + data: any; 17 + } 18 + 19 + export interface RuntimeSnapshot { 20 + id: string | null; 21 + sendMessage: string; 22 + getURL: string; 23 + getManifest: string; 24 + onMessageExternal: string; 25 + keys: string[]; 26 + hasChrome: boolean; 27 + hasBrowser: boolean; 28 + } 29 + 30 + export interface HarnessTrace { 31 + scenario: string; 32 + startedAt: string; 33 + endedAt: string; 34 + result: { ok: boolean; reason?: string }; 35 + env: { 36 + profile: string; 37 + backend: string; 38 + redactedEnvKeys: string[]; 39 + }; 40 + events: TraceEvent[]; 41 + } 42 + 43 + export interface Tracer { 44 + /** Record a console listener on a Page (top-level windows only — webview content is captured via CDP). */ 45 + attachToPage(page: Page, sourceLabel: string): void; 46 + /** Capture a chrome.runtime snapshot from a Page or webview wrapper. */ 47 + snapshotRuntime(page: Page, sourceLabel: string, opts?: { fromWebview?: string }): Promise<void>; 48 + /** Append an arbitrary event (notes, scenario milestones). */ 49 + record(kind: TraceEvent['kind'], source: string, data: any): void; 50 + /** Tear down the CDP attachment and write the final trace to disk. */ 51 + dump(result: { ok: boolean; reason?: string }): Promise<{ jsonPath: string; logPath: string }>; 52 + }
+203
tests/manual/proton-auth.harness.ts
··· 1 + /** 2 + * Manual harness scenario: Proton Pass auth flow. 3 + * 4 + * Run via: 5 + * yarn harness tests/manual/proton-auth.harness.ts 6 + * 7 + * Modes (selected via HARNESS_MODE env var): 8 + * HARNESS_MODE=interactive (default) 9 + * - Opens the Proton Pass popup, attaches the tracer, then sleeps for 10 + * HARNESS_DURATION_MS (default 180000 = 3min). You drive the UI by 11 + * hand; the tracer captures network + console + chrome.runtime + nav 12 + * events across ALL webContents (popup, auth window host, embedded 13 + * webview, BG SW). On exit, the trace is dumped to tmp/harness/. 14 + * 15 + * HARNESS_MODE=autodrive 16 + * - Reads .env.proton.local, attempts to fill username/password/TOTP. 17 + * Selectors are not yet pinned (the Proton DOM is opaque until we 18 + * see it via interactive mode); this branch records what it finds 19 + * and bails gracefully. Treat it as a placeholder for a future 20 + * iteration once the interactive trace has shown the right selectors. 21 + * 22 + * Either way, the goal is the JSON trace at tmp/harness/proton-auth-*.json. 23 + * 24 + * The harness does NOT assert success on its own. The spec passes if the 25 + * trace was produced; the actual diagnosis is a human reading the trace. 26 + */ 27 + 28 + import { test, expect } from '../fixtures/desktop-app'; 29 + import { Page } from '@playwright/test'; 30 + import path from 'path'; 31 + import fs from 'fs'; 32 + import { fileURLToPath } from 'url'; 33 + import { startTracer } from './harness/tracer'; 34 + import { discoverPopup } from './harness/popup'; 35 + import { loadEnv, assertRequiredEnv } from './harness/env'; 36 + import { getInstrumentationSource } from './harness/instrument'; 37 + 38 + const __filename = fileURLToPath(import.meta.url); 39 + const __dirname = path.dirname(__filename); 40 + const REPO_ROOT = path.join(__dirname, '..', '..'); 41 + const MODE = (process.env.HARNESS_MODE || 'interactive').toLowerCase(); 42 + const DURATION_MS = parseInt(process.env.HARNESS_DURATION_MS || '180000', 10); 43 + 44 + test.describe('proton-auth harness @manual', () => { 45 + test.setTimeout(DURATION_MS + 90_000); 46 + 47 + test('drive Proton Pass auth flow while tracer records', async ({ desktopApp }) => { 48 + const bgWindow = await desktopApp.getBackgroundWindow(); 49 + 50 + const envResult = loadEnv('.env.proton.local', REPO_ROOT); 51 + if (MODE === 'autodrive') { 52 + assertRequiredEnv(envResult.env, ['PROTON_TEST_USER', 'PROTON_TEST_PASS'], 'proton-auth'); 53 + } 54 + 55 + const tracer = await startTracer({ 56 + app: desktopApp, 57 + scenarioName: 'proton-auth', 58 + tmpDir: path.join(REPO_ROOT, 'tmp', 'harness'), 59 + profile: 'test-harness-proton-auth', 60 + backend: desktopApp.backend, 61 + redactedEnvKeys: envResult.loadedKeys, 62 + }); 63 + 64 + tracer.attachToPage(bgWindow, 'bg'); 65 + tracer.record('note', 'harness', { msg: 'starting proton-auth scenario', mode: MODE, envSource: envResult.source }); 66 + 67 + // Install page-side instrumentation on every http(s) webContents — wraps 68 + // chrome.runtime in a logging Proxy, captures window.error / 69 + // unhandledrejection, and shadows console.error/warn. All output flows 70 + // through console.log with the "[peek-instrument]" prefix and is captured 71 + // by the tracer's CDP Console domain. This surfaces signals that Proton 72 + // (Sentry, defensive try/catch around feature detection) would otherwise 73 + // swallow. 74 + // Inject instrumentation via `executeJavaScript` on every dom-ready of 75 + // any http(s) webContents. This runs AFTER the page's initial scripts, 76 + // so it can't beat synchronous chrome.runtime accesses at component 77 + // mount — but `chrome.runtime` accesses *after* install (which is most 78 + // of them, including the ongoing flow's API calls and re-mounts) are 79 + // captured. We deliberately do NOT use CDP `Page.addScriptToEvaluateOnNewDocument` 80 + // here: in this codebase, doing so on top of the tracer's already-attached 81 + // debugger session silently disrupted CDP message delivery, dropping the 82 + // entire CDP event stream. dom-ready exec is robust and gets us the 83 + // logs we need. 84 + await desktopApp.evaluateMain!(async ({ app }, args) => { 85 + app.on('web-contents-created', (_e: any, wc: any) => { 86 + wc.on('dom-ready', () => { 87 + if (wc.isDestroyed()) return; 88 + const url = wc.getURL(); 89 + if (!url.startsWith('http://') && !url.startsWith('https://')) return; 90 + wc.executeJavaScript(args.source).then( 91 + () => console.log(`[harness:instrument] exec install ok wc=${wc.id} url=${url.slice(0, 80)}`), 92 + (e: any) => console.log(`[harness:instrument] exec install FAILED wc=${wc.id} err=${e?.message ?? e}`), 93 + ); 94 + }); 95 + }); 96 + }, { source: getInstrumentationSource() }); 97 + 98 + let result: { ok: boolean; reason?: string }; 99 + try { 100 + const { popup, entry } = await discoverPopup(desktopApp, bgWindow, 'proton', { 101 + width: 400, height: 600, key: 'harness-proton-popup', 102 + }); 103 + tracer.attachToPage(popup, 'popup'); 104 + tracer.record('note', 'harness', { msg: 'popup discovered', extensionId: entry.extensionId, url: entry.url }); 105 + 106 + await popup.waitForLoadState('domcontentloaded'); 107 + await tracer.snapshotRuntime(popup, 'popup'); 108 + 109 + // Watch for any new top-level window the popup opens (auth window). 110 + // The Proton flow opens a peek://app/page/... host window that contains 111 + // the proton.me webview. 112 + let authWindow: Page | null = null; 113 + const tryFindAuth = async () => { 114 + for (const w of desktopApp.windows()) { 115 + const u = w.url(); 116 + if (u.includes('page/index.html') || u.includes('account.proton.me')) { 117 + return w; 118 + } 119 + } 120 + return null; 121 + }; 122 + 123 + if (MODE === 'autodrive') { 124 + // Placeholder: future iteration fills in real selectors once the 125 + // interactive-mode trace has shown them. For now, just wait and watch. 126 + tracer.record('note', 'harness', { msg: 'autodrive mode not yet implemented; falling through to interactive wait' }); 127 + } 128 + 129 + // Stop conditions (whichever fires first): 130 + // 1. tmp/harness-stop sentinel file appears (user signal, set via 131 + // `yarn harness:stop` from another terminal) 132 + // 2. The auth window was opened, then closed by the user (natural 133 + // "I'm done with this flow" signal) 134 + // 3. DURATION_MS elapses (hard ceiling) 135 + // 136 + // Cmd+Q on the Peek instance ALSO works: closing all top-level windows 137 + // satisfies (2) once auth-host has been seen and is now gone. 138 + const stopFile = path.join(REPO_ROOT, 'tmp', 'harness-stop'); 139 + // Pre-clean the sentinel so a stale one from a prior run doesn't trigger. 140 + try { if (fs.existsSync(stopFile)) fs.unlinkSync(stopFile); } catch {} 141 + const deadline = Date.now() + DURATION_MS; 142 + let lastUrlSeen = ''; 143 + let stopReason: string | null = null; 144 + while (Date.now() < deadline && !stopReason) { 145 + if (fs.existsSync(stopFile)) { 146 + try { fs.unlinkSync(stopFile); } catch {} 147 + stopReason = 'sentinel file'; 148 + break; 149 + } 150 + if (!authWindow) { 151 + authWindow = await tryFindAuth(); 152 + if (authWindow) { 153 + tracer.attachToPage(authWindow, 'auth-host'); 154 + tracer.record('note', 'harness', { msg: 'auth window opened', url: authWindow.url() }); 155 + // Snapshot runtime in the host page first. 156 + await tracer.snapshotRuntime(authWindow, 'auth-host').catch((e) => { 157 + tracer.record('note', 'harness', { msg: 'auth-host runtime snapshot failed', error: String(e) }); 158 + }); 159 + // And in the embedded webview if present. 160 + await tracer.snapshotRuntime(authWindow, 'auth-host:webview', { fromWebview: '#content' }).catch(() => { 161 + // fine — webview not yet attached or not present 162 + }); 163 + } 164 + } else { 165 + if (authWindow.isClosed()) { 166 + stopReason = 'auth window closed'; 167 + break; 168 + } 169 + const u = authWindow.url(); 170 + if (u !== lastUrlSeen) { 171 + lastUrlSeen = u; 172 + tracer.record('note', 'harness', { msg: 'auth host url changed', url: u }); 173 + // Re-snapshot runtime on each top-level nav of the host. 174 + await tracer.snapshotRuntime(authWindow, `auth-host@${shortPath(u)}`).catch(() => {}); 175 + await tracer.snapshotRuntime(authWindow, `auth-host:webview@${shortPath(u)}`, { fromWebview: '#content' }).catch(() => {}); 176 + } 177 + } 178 + await sleep(1000); 179 + } 180 + 181 + result = { ok: true, reason: stopReason ?? 'duration elapsed' }; 182 + tracer.record('note', 'harness', { msg: 'scenario stop', reason: result.reason }); 183 + } catch (e: any) { 184 + tracer.record('note', 'harness', { msg: 'scenario error', error: String(e?.message || e), stack: e?.stack }); 185 + result = { ok: false, reason: String(e?.message || e) }; 186 + } 187 + 188 + const { jsonPath, logPath } = await tracer.dump(result); 189 + console.log(`[harness] trace JSON: ${jsonPath}`); 190 + console.log(`[harness] trace log: ${logPath}`); 191 + 192 + // The spec passes if the trace was produced. Diagnosis is human-driven. 193 + expect(jsonPath).toBeTruthy(); 194 + }); 195 + }); 196 + 197 + function sleep(ms: number): Promise<void> { 198 + return new Promise((r) => setTimeout(r, ms)); 199 + } 200 + 201 + function shortPath(url: string): string { 202 + try { return new URL(url).pathname || '/'; } catch { return url.slice(0, 40); } 203 + }