From 98561a73d4635d34888ebe9e70fcf4e3235a2ed2 Mon Sep 17 00:00:00 2001 From: "burrito.space" Date: Wed, 3 Jun 2026 22:34:16 +0200 Subject: [PATCH] chore: move focus-probe diagnostic instruments to tools/focus-probes The 5 focus probes built during the macOS headless focus investigation move out of apps/desktop/tests/desktop so the desktop gate never picks them up. Rebased their fixture and helper imports for the new depth, added a runner playwright.config and a README documenting each probe's env var, purpose, and run command. They stay opt-in via their PEEK_FOCUS_ gates. See peek task 2b55f3af. --- tools/focus-probes/README.md | 62 ++++++++ tools/focus-probes/focus-launch-churn.spec.ts | 57 ++++++++ .../focus-probes/focus-probe-overlay.spec.ts | 88 ++++++++++++ tools/focus-probes/focus-probe-window.spec.ts | 85 +++++++++++ tools/focus-probes/focus-probe.spec.ts | 118 ++++++++++++++++ tools/focus-probes/focus-steal-repro.spec.ts | 132 ++++++++++++++++++ tools/focus-probes/playwright.config.ts | 34 +++++ 7 files changed, 576 insertions(+) create mode 100644 tools/focus-probes/README.md create mode 100644 tools/focus-probes/focus-launch-churn.spec.ts create mode 100644 tools/focus-probes/focus-probe-overlay.spec.ts create mode 100644 tools/focus-probes/focus-probe-window.spec.ts create mode 100644 tools/focus-probes/focus-probe.spec.ts create mode 100644 tools/focus-probes/focus-steal-repro.spec.ts create mode 100644 tools/focus-probes/playwright.config.ts diff --git a/tools/focus-probes/README.md b/tools/focus-probes/README.md new file mode 100644 index 00000000..9d8bc7e2 --- /dev/null +++ b/tools/focus-probes/README.md @@ -0,0 +1,62 @@ +# focus-probes + +Manual, opt-in diagnostic instruments for the **macOS headless focus** issue — +the bug where a packaged test run leaves the user's foreground app +frontmost-but-not-key (input dead until a cmd-tab cycle). Tracked as peek task +`2b55f3af`. + +These are Playwright specs that reuse the desktop app fixture, kept here +(outside `apps/desktop/tests/`) so **the desktop gate never runs them**. Each is +gated behind its own `PEEK_FOCUS_*` env var and skips unless set. + +## Running + +From the repo **root**. Build the desktop app first so the source launch has +`dist/` (or add `PACKAGED=1` to reuse `out/mac-arm64/Peek.app`): + +```bash +yarn build # once, if running source +``` + +| Probe | Env var | What it isolates | +|---|---|---| +| `focus-steal-repro.spec.ts` | `PEEK_FOCUS_STEAL_REPRO=1` | Loud A/B: alternates `prohibited` vs `regular` policy, maximizes + steals focus. **Run visible** (`HEADLESS=0 --headed`). | +| `focus-probe.spec.ts` | `PEEK_FOCUS_PROBE=1` | Single instance, steps through each in-app focus op (policy transitions, dock, `app.focus`, `showInactive`) with labeled pauses. | +| `focus-launch-churn.spec.ts` | `PEEK_FOCUS_CHURN=1` | Pure process launch/teardown churn (8 cold launches, 4 workers), NO window ops. | +| `focus-probe-window.spec.ts` | `PEEK_FOCUS_WINPROBE=1` (+ `PEEK_WINPROBE_KIND=content\|peek`) | Repeated window-open via the app path: content (page-host canvas) vs peek (workspace). | +| `focus-probe-overlay.spec.ts` | `PEEK_FOCUS_OVERLAY=1` | Repeated open/close of the `alwaysOnTop` windows switcher overlay. | + +Example (headless single-instance step probe): + +```bash +PEEK_FOCUS_PROBE=1 PEEK_TEST_WORKERS=1 HEADLESS=1 BACKEND=electron \ + npx playwright test --config tools/focus-probes/playwright.config.ts \ + focus-probe.spec.ts --workers=1 +``` + +Example (visible loud A/B repro — grabs your screen focus): + +```bash +PEEK_FOCUS_STEAL_REPRO=1 HEADLESS=0 BACKEND=electron \ + npx playwright test --config tools/focus-probes/playwright.config.ts \ + focus-steal-repro.spec.ts --workers=1 --headed +``` + +You are the oracle: macOS active-app / key-window state is not observable from +Playwright (which is why `apps/desktop/tests/desktop/headless-no-focus-theft.spec.ts` +passes while the bug exists). Type in another app during the run and note +whether input blips. + +## What these established (2026-06-03) + +- **Stuck focus** (input dead until cmd-tab) = `app.setActivationPolicy('prohibited')` + under `PEEK_HEADLESS` (the 2026-06-01 experiment, commit `c9e892fa`). Fixed by + reverting to `'accessory'` (landed). A `'prohibited'` app can never own the + macOS key window; `'accessory'` can be activated by clicking a window, so + key-window handoff works. +- **Residual blip** (focus briefly drops, then returns): NOT reproducible in any + single instance — launch/teardown churn, in-app policy/dock/`app.focus`, + content (canvas) window-open, and the `alwaysOnTop` overlay open were ALL + smooth solo. It only surfaced under 4-worker concurrency, i.e. a cross-instance + OS focus race. Still open; if it needs killing, the lever is `LSUIElement` on + the **test** build's `Info.plist` (never production) — see task `2b55f3af`. diff --git a/tools/focus-probes/focus-launch-churn.spec.ts b/tools/focus-probes/focus-launch-churn.spec.ts new file mode 100644 index 00000000..5e57aa60 --- /dev/null +++ b/tools/focus-probes/focus-launch-churn.spec.ts @@ -0,0 +1,57 @@ +/** + * Focus LAUNCH-CHURN probe — opt-in, isolates pure process launch/teardown. + * + * The single-instance focus-probe stepped through every in-app focus operation + * (policy transitions, dock, app.focus, window showInactive) and produced ZERO + * blips. The blips only appeared in the 4-worker `Window` run (many cold + * launch/teardown cycles + window-manipulation tests). This probe isolates the + * remaining variable: PURE launch/teardown churn with NO window operations. + * + * It spins up N independent per-describe app instances (each: launch → trivial + * assert → close), nothing else. Run at the default 4 workers so multiple cold + * launches overlap (the suspected race condition). If your input blips during + * this run the way it did in the `Window` run, the LAUNCH/TEARDOWN itself is + * the cause — not the window-manipulation tests. If this run is silent, the + * blip is something specific those window tests do. + * + * SKIPS unless PEEK_FOCUS_CHURN=1. Run (default workers=4 = overlapping launches): + * + * PEEK_FOCUS_CHURN=1 HEADLESS=1 BACKEND=electron \ + * npx playwright test apps/desktop/tests/desktop/focus-launch-churn.spec.ts \ + * --config apps/desktop/playwright.config.ts --project=desktop + * + * Type continuously in another app throughout and note any input blips. + */ + +import { test, expect, DesktopApp } from '../../apps/desktop/tests/fixtures/desktop-app'; +import { createPerDescribeApp } from '../../apps/desktop/tests/helpers/test-app'; + +test.skip( + process.env.PEEK_FOCUS_CHURN !== '1', + 'opt-in launch-churn probe — set PEEK_FOCUS_CHURN=1', +); + +// Each describe = one independent cold launch (createPerDescribeApp) + teardown, +// with NO window operations. 8 of them across 4 workers = overlapping churn. +const LAUNCHES = 8; + +for (let i = 0; i < LAUNCHES; i++) { + test.describe(`Launch churn ${i} @desktop`, () => { + let app: DesktopApp; + + test.beforeAll(async () => { + ({ app } = await createPerDescribeApp(`focus-churn-${i}`)); + }); + + test.afterAll(async () => { + if (app) await app.close(); + }); + + test(`cold launch ${i} then idle, no window ops`, async () => { + // Hold the instance briefly so launches overlap across workers and there + // is a window to type against. No show/focus/policy calls. + await new Promise((r) => setTimeout(r, 2500)); + expect(true).toBe(true); + }); + }); +} diff --git a/tools/focus-probes/focus-probe-overlay.spec.ts b/tools/focus-probes/focus-probe-overlay.spec.ts new file mode 100644 index 00000000..b62ccff4 --- /dev/null +++ b/tools/focus-probes/focus-probe-overlay.spec.ts @@ -0,0 +1,88 @@ +/** + * Focus OVERLAY probe — opt-in, tests the alwaysOnTop switcher overlay. + * + * Narrowing so far EXONERATED: launch/teardown churn, in-app ops + * (policy/dock/app.focus/raw showInactive), and content (page-host canvas) + + * peek (workspace) window-open via the app path — all smooth, single instance. + * + * The only choppy run was the 4-worker `Window` grep, which matched + * `windows-lazy-wake.spec.ts` ("Windows Lazy Wake") — and that opens the + * windows SWITCHER OVERLAY (role:'overlay', alwaysOnTop, fullscreen) ~2 times, + * matching the "two distinct blips" observed. Also: the only earlier probe that + * DID blip used setAlwaysOnTop(true); every smooth probe since did not. + * + * Hypothesis: opening an alwaysOnTop overlay (the switcher) grabs OS focus. + * This probe opens + closes the switcher overlay repeatedly, single instance, + * via the real lazy-wake path (publish cmd:execute:windows). Type continuously; + * if THIS is choppy while the content/peek probes were smooth, the alwaysOnTop + * overlay open is the exact cause. + * + * SKIPS unless PEEK_FOCUS_OVERLAY=1. Run cool (single instance), headless: + * + * PEEK_FOCUS_OVERLAY=1 PEEK_TEST_WORKERS=1 HEADLESS=1 BACKEND=electron \ + * npx playwright test apps/desktop/tests/desktop/focus-probe-overlay.spec.ts \ + * --config apps/desktop/playwright.config.ts --project=desktop --workers=1 + */ + +import { test, expect, DesktopApp } from '../../apps/desktop/tests/fixtures/desktop-app'; +import { Page } from '@playwright/test'; +import { createPerDescribeApp } from '../../apps/desktop/tests/helpers/test-app'; +import { waitForExtensionsReady } from '../../apps/desktop/tests/helpers/window-utils'; + +const OPENS = 6; + +test.skip( + process.env.PEEK_FOCUS_OVERLAY !== '1', + 'opt-in overlay probe — set PEEK_FOCUS_OVERLAY=1 and run with --workers=1', +); + +test.describe('Focus Overlay Probe @desktop', () => { + let app: DesktopApp; + let bgWindow: Page; + + test.beforeAll(async () => { + ({ app, bgWindow } = await createPerDescribeApp('focus-overlay-probe')); + await waitForExtensionsReady(bgWindow, 15000); + }); + + test.afterAll(async () => { + if (app) await app.close(); + }); + + test('repeatedly open + close the alwaysOnTop windows switcher overlay', async () => { + test.setTimeout(OPENS * 12000 + 40000); + + console.log('\n[ovprobe] ============================================================'); + console.log('[ovprobe] Settling 6s — then open+close the windows SWITCHER OVERLAY'); + console.log(`[ovprobe] ${OPENS} times (alwaysOnTop, fullscreen). TYPE CONTINUOUSLY in`); + console.log('[ovprobe] your other app the whole time. Report: choppy or smooth?'); + console.log('[ovprobe] ============================================================\n'); + + await new Promise((r) => setTimeout(r, 6000)); + + for (let i = 0; i < OPENS; i++) { + // Open the switcher via the real lazy-wake path (what the hotkey does). + await app.evaluateMain!(((_e: unknown) => { + (globalThis as any).__peek_test.publishCmdExecute('windows'); + }) as any); + + const overlay = await app.getWindow('windows/windows.html', 10000).catch(() => null); + console.log(`[ovprobe] open ${i + 1}/${OPENS} switcher overlay present=${!!overlay} <-- watch now`); + await new Promise((r) => setTimeout(r, 2500)); + + // Close the overlay so the next iteration re-opens it fresh. + await app.evaluateMain!(((electron: typeof import('electron')) => { + const { BrowserWindow } = electron; + for (const w of BrowserWindow.getAllWindows()) { + try { + if (!w.isDestroyed() && w.webContents.getURL().includes('windows/windows.html')) w.close(); + } catch { /* gone */ } + } + }) as any); + await new Promise((r) => setTimeout(r, 2000)); + } + + console.log('\n[ovprobe] DONE. Was your typing choppy or smooth during the overlay opens?\n'); + expect(true).toBe(true); + }); +}); diff --git a/tools/focus-probes/focus-probe-window.spec.ts b/tools/focus-probes/focus-probe-window.spec.ts new file mode 100644 index 00000000..bdd38df8 --- /dev/null +++ b/tools/focus-probes/focus-probe-window.spec.ts @@ -0,0 +1,85 @@ +/** + * Focus WINDOW probe — opt-in, isolates the window-OPEN path by REPETITION. + * + * Prior probes exonerated launch/teardown churn and in-app ops. The blip only + * appeared with the real `Window*` tests, which open windows via the app's + * window.open path. This probe opens ONE kind of window repeatedly so the + * effect is cumulative — you don't need to know exact timing, just whether your + * typing catches repeatedly (choppy) or stays smooth across the whole run. + * + * KIND=content (default) → about:blank via app.window.open → page-host CANVAS + * KIND=peek → peek://app/settings/settings.html → workspace window + * + * Run it once per kind and compare: + * PEEK_FOCUS_WINPROBE=1 PEEK_WINPROBE_KIND=content PEEK_TEST_WORKERS=1 \ + * HEADLESS=1 BACKEND=electron npx playwright test \ + * apps/desktop/tests/desktop/focus-probe-window.spec.ts \ + * --config apps/desktop/playwright.config.ts --project=desktop --workers=1 + * + * If CONTENT is choppy and PEEK is smooth, the page-host canvas window-open is + * the cause. SKIPS unless PEEK_FOCUS_WINPROBE=1. + */ + +import { test, expect, DesktopApp } from '../../apps/desktop/tests/fixtures/desktop-app'; +import { Page } from '@playwright/test'; +import { createPerDescribeApp } from '../../apps/desktop/tests/helpers/test-app'; + +test.skip( + process.env.PEEK_FOCUS_WINPROBE !== '1', + 'opt-in window-open probe — set PEEK_FOCUS_WINPROBE=1 and run with --workers=1', +); + +const KIND = process.env.PEEK_WINPROBE_KIND === 'peek' ? 'peek' : 'content'; +const URL = KIND === 'peek' ? 'peek://app/settings/settings.html' : 'about:blank'; +const OPENS = 8; + +test.describe('Focus Window Probe @desktop', () => { + let app: DesktopApp; + let bgWindow: Page; + + test.beforeAll(async () => { + ({ app, bgWindow } = await createPerDescribeApp('focus-win-probe')); + }); + + test.afterAll(async () => { + if (app) await app.close(); + }); + + test(`repeatedly open+close ${KIND} windows`, async () => { + const HOLD_MS = 1800; + const GAP_MS = 900; + test.setTimeout(OPENS * (HOLD_MS + GAP_MS + 4000) + 30000); + + console.log(`\n[winprobe] ============================================================`); + console.log(`[winprobe] KIND=${KIND} url=${URL}`); + console.log(`[winprobe] Settling 6s — then ${OPENS} open+close cycles. TYPE CONTINUOUSLY`); + console.log(`[winprobe] in your other app the WHOLE time. Report: choppy or smooth?`); + console.log(`[winprobe] ============================================================\n`); + + // Settle so the launch is well behind us before the opens begin. + await new Promise((r) => setTimeout(r, 6000)); + + for (let i = 0; i < OPENS; i++) { + const opened = await bgWindow.evaluate(async (url: string) => { + try { + return await (window as any).app.window.open(url, { width: 700, height: 500 }); + } catch (e) { + return { success: false, error: String(e) }; + } + }, URL); + console.log(`[winprobe] open ${i + 1}/${OPENS} ${KIND} -> ${JSON.stringify(opened)}`); + await new Promise((r) => setTimeout(r, HOLD_MS)); + + const id = (opened as any)?.id; + if (id != null) { + await bgWindow.evaluate(async (wid: number) => { + try { await (window as any).app.window.close(wid); } catch { /* gone */ } + }, id); + } + await new Promise((r) => setTimeout(r, GAP_MS)); + } + + console.log(`\n[winprobe] DONE (${KIND}). Was your typing choppy or smooth across the run?\n`); + expect(true).toBe(true); + }); +}); diff --git a/tools/focus-probes/focus-probe.spec.ts b/tools/focus-probes/focus-probe.spec.ts new file mode 100644 index 00000000..27cff456 --- /dev/null +++ b/tools/focus-probes/focus-probe.spec.ts @@ -0,0 +1,118 @@ +/** + * Focus PROBE — decompose the focus blip, opt-in, single instance. + * + * We confirmed the *stuck* focus came from setActivationPolicy('prohibited'). + * Under 'accessory' a residual *blip* remains (focus briefly taken, then + * returned). This probe isolates WHICH operation causes that blip by stepping + * through the distinct focus-affecting things a test run does — ONE per step, + * with a labeled pause and a timestamp — in a single headless instance. + * + * Each step prints `[probe] STEP k t=