From 2d7460b3f162c06ddb71630949e101cb2ba6e457 Mon Sep 17 00:00:00 2001 From: Jeff Tindell Date: Mon, 12 Oct 2026 14:40:51 -0400 Subject: [PATCH 01/78] docs(theming): skeleton for theme pack inventory Co-Authored-By: Claude Sonnet 5.5 --- docs/theming/THEME-PACK-INVENTORY.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/theming/THEME-PACK-INVENTORY.md diff --git a/docs/theming/THEME-PACK-INVENTORY.md b/docs/theming/THEME-PACK-INVENTORY.md new file mode 100644 index 0000000000..07ede177e0 --- /dev/null +++ b/docs/theming/THEME-PACK-INVENTORY.md @@ -0,0 +1,16 @@ +# Theme Pack Inventory + +## 1. Inventory of themeable surfaces +TODO + +## 2. The pack format +TODO + +## 3. Switch layers (cast-reseat vs. live) +TODO + +## 4. Migration plan +TODO + +## 5. Asset / license notes +TODO From 27cb708c74d3a78cf09eadd1a690eacc64835292 Mon Sep 17 00:00:00 2001 From: Jeff Tindell Date: Mon, 12 Oct 2026 14:43:12 -0400 Subject: [PATCH 02/78] docs(theming): section 2, the theme pack format Co-Authored-By: Claude Sonnet 5.5 --- docs/theming/THEME-PACK-INVENTORY.md | 229 ++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 3 deletions(-) diff --git a/docs/theming/THEME-PACK-INVENTORY.md b/docs/theming/THEME-PACK-INVENTORY.md index 07ede177e0..40835bd401 100644 --- a/docs/theming/THEME-PACK-INVENTORY.md +++ b/docs/theming/THEME-PACK-INVENTORY.md @@ -1,10 +1,233 @@ -# Theme Pack Inventory +# Theme Pack Inventory & Format Spec + +Status: analysis only (no source changed). Paths are relative to `src/renderer/src/` unless prefixed +`main/` / `preload/` (= `src/main`, `src/preload`). Line numbers are as of branch +`agent/worker-phase-a-inventory-v2`. + +**Goal.** Given a folder that meets size + naming rules, the app loads it as a theme. We do not +hand-author themes. "Munder Difflin" is pack #1: the current office look, migrated byte-identical. ## 1. Inventory of themeable surfaces TODO -## 2. The pack format -TODO +## 2. The pack format (centerpiece) + +### 2.0 Starting point (what already exists) +`scene/office/themeRegistry.ts` defines `ThemeConfig` (map + tilesets + seat/cafe names + coffee + +anchors + errands + monitor gids + palette + cast) and `themeLoader.ts` has an async +`loadTheme(id)` that already validates lightly and falls back to `OFFICE_THEME`. What blocks +"drop in a folder": (a) `ThemeId` is a closed union (`themeRegistry.ts:36`, mirrored in +`main/config.ts:320`, `store/config.ts:127`, `preload/index.ts:327`, `main/realtimeActions.ts:142`); +(b) the map and tilesets are build-time Vite `?raw`/`?url` imports (`themeRegistry.ts:23-28`); +(c) the cast is compiled TS (`cast.ts` + `portraitArt.ts`); (d) chrome tokens, copy and terminal +palette are not part of the contract at all. The pack format below makes `ThemeConfig` the +**in-memory** shape and adds a **JSON manifest** as the on-disk shape, extending rather than +replacing the existing field names. + +### 2.1 Folder structure +A pack is one directory. The directory name is the pack id (`[a-z0-9][a-z0-9-]{1,39}`). + +``` +/ + theme.json REQUIRED manifest (2.2) + map.tmj REQUIRED Tiled JSON map (2.4) + tilesets/ REQUIRED 1..8 PNG atlases, referenced by theme.json + .png + preview.png optional picker thumbnail, 320x180 (falls back to a generated swatch) + CREDITS.md REQUIRED if any asset is third-party (see §5); surfaced in About/credits + cast/ optional omit => default roster (see 2.7) + cast.json roster metadata (CastMember[]) + portraits/.png one per cast member, 36x64 (2x of the 18x32 scene frame) + sprites/.png one per cast member, 108x32 (6 frames of 18x32, see 2.4) + chrome/ optional live-switchable, no reseat + tokens.css only `--cth-*` custom properties inside `:root{}` / `[data-cth-theme=dark]{}` + fonts/*.woff2 latin subsets; declared in theme.json `fonts[]` + terminal.json optional { light: {...xterm ITheme}, dark: {...} } + copy/.json optional PARTIAL i18n override, same keys as i18n/locales/.json +``` + +Rules: no symlinks, no `..`, no absolute paths, no files outside this tree are read. Unknown files +are ignored. Total pack size cap 24 MB (soft warn at 12 MB). + +### 2.2 Manifest: `theme.json` +```jsonc +{ + "schema": 1, // REQUIRED integer; loader supports 1..N, rejects higher w/ warning + "id": "munder-difflin", // REQUIRED, must equal directory name + "name": "Munder Difflin", // REQUIRED display name (picker) + "version": "1.0.0", // REQUIRED semver + "author": "…", "license": "MIT", // REQUIRED strings (license of pack's own content) + "blurb": "The original floor", // optional + "swatch": "#6b5a4a", // optional picker colour if no preview.png + + "map": "map.tmj", + "tilesets": [ // order == map tileset order == texture load order + { "file": "tilesets/office-tileset.png", "embedded": true }, + { "file": "tilesets/a5-office-floors-walls.png", "firstgid": 513 }, + { "file": "tilesets/interiors.png", "firstgid": 1025 } + ], // firstgid optional: loader assigns 1, then prev+tilecount + + "seats": { "primary": ["desk-ceo","pc-1", "…"], "cafe": ["cafe-seat-1","…"], + "cafeStands": [["cafe-stand-coffee","coffee"],["cafe-stand-vending","vending"]] }, + "coffee": { "trayTile":{"x":29,"y":15}, "trayStand":{…}, "machineStand":{…}, + "sinkTile":{…}, "sinkStand":{…}, "maxCups":4 }, // optional => coffee loop disabled + "anchors": { "calendar":{"x":4,"y":1}, "boards":{"x":6,"y":10}, "clock":{"x":1,"y":1} }, + "errandSpots": [ { "kind":"water","stand":{…},"facing":"left","fx":{…},"duration":4.5,"godOnly":false } ], + "monitor": { "offTopLeftGid":365, "onGids":[[367,0,0],[368,1,0],[383,0,1],[384,1,1]] }, + "palette": { "background":"#1A1320", "noteColors":{"todo":"#f2df8a","doing":"#9ecbf0","blocked":"#f0a3a3","done":"#a8e0b0"} }, + + "fonts": [ { "family":"Inter", "file":"chrome/fonts/inter.woff2", "weight":"400 700", "slot":"ui" } ], + "chrome": { "tokens": "chrome/tokens.css", "defaultMode": "light" }, + "terminal": "terminal.json", + "copy": { "appName": "Munder Difflin", "godName": "Michael" }, // small typed set; bulk strings go in copy/.json + "cast": { "roster": "cast/cast.json", "default": "jim" } +} +``` +Field names deliberately equal `ThemeConfig` so the TS interface is a superset of the existing one: +`seats.primary` = `primarySeatNames`, `seats.cafe` = `cafeSeatNames`, etc. Colours are `#rrggbb` +strings on disk (the in-memory `PaletteConfig` stays `number`, converted by the loader via +`hexNum`/`hexToNumber`, as `OfficeFloor.tsx:247` and `cast.ts:50` already do). + +### 2.3 Naming conventions (hard requirements) +| Thing | Rule | +|---|---| +| pack dir / `id` | `^[a-z0-9][a-z0-9-]{1,39}$`; reserved: `office`, `default`, `none` (legacy alias, see §4) | +| manifest | exactly `theme.json` (lowercase) | +| map | `map.tmj` (Tiled JSON; `.json` not accepted) | +| tilesets | any `tilesets/*.png`, lowercase `[a-z0-9._-]`, PNG only | +| map layers (exact names; `TiledMapRenderer.ts:259` looks up by name+type) | tilelayers `floor`, `walls`, `furniture-below`, `furniture-above`, `collision`; objectgroups `spawn-points`, `zones` | +| required spawn points | `desk-ceo` (god's seat) plus >=1 more in `seats.primary`; every name in `seats.*` must exist in `spawn-points` | +| zones | `boardroom`, `cafeteria` (optional; absent => feature off) | +| cast files | `` = lowercase `[a-z0-9-]`, must match a `cast.json` entry; `portraits/` and `sprites/` required together | +| tokens | only `--cth-*` names that exist in `design/tokens.css`; others dropped with a warning | +| `noteColors` keys | `todo`, `doing`, `blocked`, `done` | + +### 2.4 Dimension / size requirements +Engine constants (NOT themeable): **tile = 16x16 px** (every tileset and the map; see the +`TilesetEntry` literals at `themeRegistry.ts:163-164`); cast scene frame = **18x32 px** +(`SCENE_W`/`SCENE_H`, `portraitArt.ts:15-16`). + +| Asset | Requirement | Hard limit (reject) | +|---|---|---| +| tileset PNG | width and height multiples of 16; `columns = width/16` derived, never trusted from manifest; RGBA | width <= 2048, height <= 8192, file <= 4 MB, <= 8 tilesets | +| map | `tilewidth=tileheight=16`, `infinite:false`, orthogonal, plain-array `data` (no zlib/base64) | width,height in 8..128 tiles; JSON <= 2 MB | +| map gids | every non-zero gid must fall in a declared tileset's `[firstgid, firstgid+tilecount)` | unresolved gid => tile skipped + warning; >5% unresolved => reject | +| cast sprite sheet | **108x32**: six 18x32 frames left to right: front stand, front step-L, front step-R, back stand, back step-L, back step-R (mirrors `sceneFrameBufs() -> {front[3], back[3]}`, `cast.ts:80`) | exact size or member skipped | +| cast portrait | **36x64** (2x the scene frame; `paintCastPortrait` default scale 2) | exact size or member skipped | +| preview | 320x180 | PNG <= 512 KB, else ignored | +| fonts | woff2 only, <= 300 KB each, <= 4 files | else ignored (system stack stays) | +| tokens.css | <= 16 KB, no `url()`, no `@import`, only `--cth-*` declarations | else ignored | +| cast roster | 1..40 members; `shirt` is `#rrggbb`; blurb <= 60 chars | | + +Reference values from the existing office: map 34x22 tiles, 5 tilelayers + 2 objectgroups, 24 spawn +points; atlases 256x512 (a5), 256x1424 (interiors), office-tileset 16 columns / 512 tiles. + +### 2.5 Validation + graceful-fallback contract +One function owns this: `validatePack(rawFiles) -> { pack: ThemePack | null, issues: Issue[] }`, +pure and unit-testable (no fs, no Pixi). It runs in **main** at scan time (structural checks, byte +sizes, PNG header dims) and again in the **renderer** before the scene touches the pack. +`Issue = { level: 'error'|'warn', code, path, message }`. + +**Errors (pack rejected whole, never partially applied):** missing/unparseable `theme.json` or +`map.tmj`; unsupported `schema`; `id` != directory name; map missing a required layer or +`desk-ceo`; any `seats.*` name absent from `spawn-points`; a tileset missing / not PNG / over +limits; map over limits; >5% unresolved gids. + +**Warnings (pack loads; the one field falls back to the default pack's value):** bad `coffee` / +individual `errandSpots` (feature disabled / spot dropped; stand tile out of bounds or on a +collision cell); bad `monitor` (desks render without ON overlay); invalid cast member (skipped; if +roster ends empty => default roster); bad font/tokens/terminal/copy (ignored); missing preview. + +**Fallback chain (single, deterministic):** requested id -> last-known-good id (persisted in +config) -> built-in `munder-difflin`. `loadTheme()` stays total — today it already returns a theme +on any failure (`themeLoader.ts:58-65`); the new version returns `{ theme, usedFallbackFor?, +issues }` and the UI shows a non-blocking notice. Rules that keep the floor alive: +1. The picker runs `themes:validate` BEFORE the destructive confirm modal; an invalid pack never + reaches `applyTheme` (`OfficeThemePicker.tsx:64`), so a bad pack can never archive live agents. +2. An exception inside `OfficeFloor.init` (`OfficeFloor.tsx:246`) is caught and the scene re-inits + once with the built-in pack. +3. Size caps are enforced **in main before bytes cross IPC** (a 50 MB PNG never reaches renderer). +4. Every issue is logged so a bad pack is diagnosable. + +### 2.6 Discovery (where packs live) +Search order; first id wins, collisions are logged and the later one is shown as unavailable: +1. **Built-in:** `/themes//` — shipped via `electron-builder.yml` + `extraResources` (block starts at line 62; add `resources/themes -> themes`). In dev: + `/resources/themes`. +2. **User:** `/themes//` (`app.getPath('userData')`, same root as + `main/config.ts:491` and `main/db.ts:81`). Settings gets an "Open themes folder" button. +3. **Dev override:** env `MD_THEMES_DIR` (path-delimiter list) so authors iterate without touching + userData (see the shared-userData hazard in §5). + +Scan = startup + explicit "Rescan" (no fs watcher in v1). A scan returns lightweight +`PackSummary { id, name, version, blurb, swatch, source, issues[] }`; heavy bytes load on demand. + +### 2.7 TypeScript contract and load path +```ts +// scene/office/themePack.ts (new). ThemeConfig stays: it is the resolved scene half. +export type ThemeId = string; // was a closed union + +export interface PackManifest { // == theme.json, 1:1 + schema: 1; id: ThemeId; name: string; version: string; author: string; license: string; + blurb?: string; swatch?: string; map: string; + tilesets: { file: string; embedded?: boolean; firstgid?: number }[]; + seats: { primary: string[]; cafe: string[]; cafeStands: [string, 'coffee' | 'vending'][] }; + coffee?: CoffeeConfig; anchors: AnchorConfig; errandSpots: ErrandSpot[]; + monitor: MonitorConfig; + palette: { background: string; noteColors: Record }; + fonts?: { family: string; file: string; weight?: string; slot?: 'display' | 'ui' | 'mono' }[]; + chrome?: { tokens?: string; defaultMode?: 'light' | 'dark' }; + terminal?: string; + copy?: { appName?: string; godName?: string }; + cast?: { roster: string; default: string }; +} + +/** What main returns over IPC: manifest + the already size-checked bytes. */ +export interface PackPayload { + manifest: PackManifest; + files: Record; // key = pack-relative path + source: 'builtin' | 'user' | 'dev'; + issues: Issue[]; +} + +/** Runtime object: a ThemeConfig plus the non-scene layers. */ +export interface ThemePack extends ThemeConfig { + meta: { name: string; version: string; author: string; license: string; source: string }; + chrome?: { tokensCss?: string; fontFaces: FontFace[]; defaultMode?: AppTheme }; + terminal?: { light?: ITheme; dark?: ITheme }; + copy?: Record>; + disposeUrls(): void; // revokes blob: URLs +} +``` +**Load path — ONE path for built-in and user packs.** +- `main/themes.ts` (new): `scanThemes()`, `readPack(id)` (reads the dir, enforces §2.4, returns + `PackPayload`). Registered next to the existing fs handlers (`main/index.ts:3330-3340`): + `ipcMain.handle('themes:list' | 'themes:load' | 'themes:validate' | 'themes:openFolder')`. Args + are string-checked; `id` is matched against the scanned set, never path-joined from raw input. +- `preload/index.ts`: add `listThemes()`, `loadThemePack(id)`, `validateTheme(id)`, + `openThemesFolder()` to the `cth` bridge (`exposeInMainWorld` at `preload/index.ts:1429`). +- Renderer: `themeLoader.loadTheme(id)` calls `window.cth.loadThemePack(id)` and builds `blob:` + URLs (`URL.createObjectURL`) for tilesets/fonts/portraits — the technique the repo already uses + for images, because the CSP (`index.html:6`, `img-src 'self' data: blob:`) has no `file:` source + and no file protocol is registered (`main/index.ts:3334-3338`). **No CSP change.** (Alternative, + rejected for v1: a `cth-theme://` protocol + widened `img-src` — a CSP widening for no gain.) + `resolveThemeMap` / `themeTilesetUrls` keep their signatures, so the scene call sites + (`OfficeFloor.tsx:281-285`) are untouched. +- **Superseding the registry:** delete the Vite `?url`/`?raw` imports at `themeRegistry.ts:23-28`. + `THEMES`/`getTheme` become a runtime `Map` filled by `loadTheme`. The + `OFFICE_THEME` / `BROOKLYN99_THEME` literals become two `theme.json` files (§4). +- **Config:** `officeTheme` becomes `string` (the four mirrors in §2.0); the hard enum at + `realtimeActions.ts:142` becomes a runtime check against the scanned id set. +- **i18n:** `copy/.json` is merged with `i18n.addResourceBundle(lng, 'translation', obj, + true, true)` on activate and the base bundle restored on deactivate (i18n is initialised with + static `resources` at `i18n/index.ts:110`). +- **Chrome:** tokens.css injected as one ` +
diff --git a/test/electron-harness/pty-quit-main.cjs b/test/electron-harness/pty-quit-main.cjs new file mode 100644 index 0000000000..096f569cbf --- /dev/null +++ b/test/electron-harness/pty-quit-main.cjs @@ -0,0 +1,54 @@ +'use strict'; + +/** + * EXIT-CRASH (Jim, EXIT-CRASH-WHY.md): a hidden Electron main that owns 4 REAL ConPTY sessions + * (node-pty, cmd.exe), quits the way the app does, and exits at once. With the local crash + * reporter on (dumps into the SANDBOX only), the parent counts the dumps: + * --mode old : tree kill + proc.kill, then app.exit (1.1.65-1.1.67): a node-pty exit callback + * lands during teardown -> Napi::Error -> a dump. + * --mode new : PtyManager.killAllAsync (waits for every exit, capped), then app.exit: no dump. + * userData, sessionData and crashDumps all live in the sandbox; no window is created. + */ +const { app, crashReporter } = require('electron'); +const { isolateAppPaths } = require('./isolate-paths.cjs'); +const { join } = require('node:path'); + +const argOf = (n, d) => { const i = process.argv.indexOf(`--${n}`); return i >= 0 ? process.argv[i + 1] : d; }; +const sandbox = argOf('sandbox', null); +const mode = argOf('mode', 'new'); +if (!sandbox) { process.stderr.write('pty-quit harness: --sandbox required\n'); process.exit(2); } +// HARNESS-CRASHPAD: every path, crashDumps included, inside the sandbox (asserted). +const dumps = isolateAppPaths(app, sandbox, { reporter: false }).crashDumps; // its own reporter below +crashReporter.start({ uploadToServer: false, submitURL: '', compress: false }); + +const loadTs = require(join(__dirname, '..', 'load-ts.cjs')); +const { PtyManager } = loadTs('src/main/pty.ts'); +const { killTreesAsync } = loadTs('src/main/procKill.ts'); +const pty = require('node-pty'); + +app.whenReady().then(async () => { + const m = new PtyManager(); + // FLAKY-TIMING (Andy, flaky-170): the gate is the ORDER - app.exit only after every ConPTY exit + // arrived (pending 0, no dump). With the production 1.5 s cap, a saturated machine delivering + // the 4 exits later than 1.5 s failed the gate without any defect. The cap's own value and + // behaviour are pinned in quit-hang-157 (EXIT-CRASH tests); here it is only a hang guard. + const productionCap = PtyManager.EXIT_WAIT_MS; + PtyManager.EXIT_WAIT_MS = 60_000; + const procs = []; + for (let i = 0; i < 4; i += 1) { + const p = pty.spawn('cmd.exe', ['/k'], { name: 'xterm-256color', cols: 80, rows: 24, cwd: sandbox, env: process.env, useConpty: true }); + p.onData(() => {}); + procs.push(p); + m.sessions.set(`t${i}`, { proc: p }); + } + await new Promise((r) => setTimeout(r, 1500)); // let the shells come up + const t0 = Date.now(); + if (mode === 'old') { + const sessions = [...m.sessions.values()]; m.sessions.clear(); + await killTreesAsync(sessions.map((s) => s.proc.pid)).then(() => { for (const s of sessions) { try { s.proc.kill(); } catch { /* noop */ } } }); + } else { + await m.killAllAsync(); + } + process.stdout.write(`__PTYQUIT__${JSON.stringify({ mode, ms: Date.now() - t0, pending: m.exitsPending, productionCap })}\n`); + app.exit(0); +}); diff --git a/test/electron-harness/recovery-main.cjs b/test/electron-harness/recovery-main.cjs new file mode 100644 index 0000000000..67785657bd --- /dev/null +++ b/test/electron-harness/recovery-main.cjs @@ -0,0 +1,151 @@ +'use strict'; + +/** + * RENDERER-RECOVERY-164: the recovery module driven by a REAL, never-shown BrowserWindow whose + * renderer is really crashed with webContents.forcefullyCrashRenderer(). + * + * A stand-in "terminal" streams `pty:data:t1` every 40 ms to its OWNER webContents, the way + * PtyManager routes a PTY (owner read at send time, reassigned on recreate). The page counts + * the chunks it receives after each load, so "the terminal reattached" is observed, not + * assumed. Sequence: + * crash 1 -> the SAME webContents reloads; data flows again; a reload notice is set + * crash 2 -> a NEW window replaces it; the stream's owner moves to it; the old one is + * destroyed without its 'close' firing; data flows to the new page + * crash 3 -> recovery gives up: no reload, no new window; the stream keeps running in main + * The parent (run in the test) owns the sandbox directory, as with the other harnesses. + */ +const { app, BrowserWindow, ipcMain } = require('electron'); +const { isolateAppPaths } = require('./isolate-paths.cjs'); +const { writeFileSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const ts = require('typescript'); + +const MARKER = '__RECOVERY_RESULT__'; +const argOf = (n, d) => { const i = process.argv.indexOf(`--${n}`); return i >= 0 ? process.argv[i + 1] : d; }; +const sandbox = argOf('sandbox', null); +if (!sandbox) { process.stderr.write('recovery harness: --sandbox required\n'); process.exit(2); } +// HARNESS-CRASHPAD: this harness crashes its renderer on purpose; without crashDumps in the +// sandbox, those dumps landed in the live app's %APPDATA%/munder-difflin/Crashpad. +isolateAppPaths(app, sandbox); + +// The real module, transpiled in-process (it has no imports). +const src = readFileSync(join(__dirname, '..', '..', 'src', 'main', 'rendererRecovery.ts'), 'utf8'); +const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; +const mod = { exports: {} }; +new Function('module', 'exports', 'require', js)(mod, mod.exports, require); +const R = mod.exports; + +const page = join(sandbox, 'page.html'); +writeFileSync(page, `recovery`); + +const result = { steps: [], ok: false }; +const note = (s) => { result.steps.push({ t: Date.now(), ...s }); }; +const finish = (extra = {}) => { + Object.assign(result, extra); + process.stdout.write(`${MARKER}${JSON.stringify(result)}\n`); + app.exit(0); +}; +setTimeout(() => finish({ error: 'timeout' }), 60_000).unref?.(); + +let owner = null; // the stream's owner webContents (PtyManager semantics) +const notices = new Map(); // wc.id -> notice (one-shot) +const closeEvents = []; // any 'close' on a window (must stay empty) +const gaveUp = []; +let destroyed = []; +setInterval(() => { if (owner && !owner.isDestroyed()) { try { owner.send('pty:data:t1', 'x'); } catch { /* gone */ } } }, 40); + +ipcMain.handle('page:notice', (e) => { const n = notices.get(e.sender.id) ?? null; notices.delete(e.sender.id); return n; }); + +const waiters = []; +// An event that arrives BEFORE its waiter is registered is kept, not lost: under a busy machine +// the page can report 'got3' before the harness awaits it (that race once failed the suite). +const early = []; +const waitFor = (name, pred = () => true, ms = 15_000) => new Promise((resolve, reject) => { + const hit = early.findIndex((e) => e.name === name && pred(e.data)); + if (hit >= 0) { const [e] = early.splice(hit, 1); resolve(e.data); return; } + const w = { name, pred, resolve }; + waiters.push(w); + setTimeout(() => { const i = waiters.indexOf(w); if (i >= 0) { waiters.splice(i, 1); reject(new Error(`timed out waiting for ${name}`)); } }, ms); +}); +const emit = (name, data) => { + const w = waiters.find((x) => x.name === name && x.pred(data)); + if (w) { waiters.splice(waiters.indexOf(w), 1); w.resolve(data); } else early.push({ name, data }); +}; +ipcMain.on('page:ready', (e, n) => emit('ready', { wcId: e.sender.id, notice: n })); +ipcMain.on('page:got3', (e) => emit('got3', { wcId: e.sender.id })); + +const policy = new R.RecoveryPolicy(); +function makeWindow() { + const win = new BrowserWindow({ show: false, width: 400, height: 300, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false } }); + win.on('close', () => closeEvents.push(win.webContents.id)); + win.loadFile(page); + return win; +} +const deps = { + policy, + now: () => Date.now(), + setTimer: (fn, ms) => setTimeout(fn, ms), + log: (row) => { note({ row }); emit('gone', row); }, + quitting: () => false, + setNotice: (w, n) => { notices.set(w.webContents.id, n); }, + recreate: (old) => { + const next = makeWindow(); + if (owner === old.webContents) owner = next.webContents; // reassignOwner + destroyed.push(old.webContents.id); + old.destroy(); + return next; + }, + install: (w) => R.installRendererRecovery(w, deps), + giveUp: (w, d) => { gaveUp.push(d); emit('gaveUp', d); } +}; + +app.whenReady().then(async () => { + try { + let win = makeWindow(); + R.installRendererRecovery(win, deps); + owner = win.webContents; + const first = await waitFor('ready'); + await waitFor('got3', (d) => d.wcId === first.wcId); + const firstPid = win.webContents.getOSProcessId(); + note({ phase: 'loaded', wcId: first.wcId, pid: firstPid, goneListeners: win.webContents.listenerCount('render-process-gone') }); + + // Crash 1: reload the SAME webContents. + win.webContents.forcefullyCrashRenderer(); + const gone1 = await waitFor('gone'); + const re = await waitFor('ready', (d) => d.wcId === first.wcId); + await waitFor('got3', (d) => d.wcId === first.wcId); + const pidAfterReload = win.webContents.getOSProcessId(); + note({ phase: 'reloaded', sameWc: re.wcId === first.wcId, notice: re.notice, newPid: pidAfterReload !== firstPid, goneListeners: win.webContents.listenerCount('render-process-gone') }); + + // Crash 2 (within 2 min): recreate once. + win.webContents.forcefullyCrashRenderer(); + const gone2 = await waitFor('gone'); + const fresh = await waitFor('ready', (d) => d.wcId !== first.wcId); + await waitFor('got3', (d) => d.wcId === fresh.wcId); + const all = BrowserWindow.getAllWindows(); + win = all.find((w) => w.webContents.id === fresh.wcId); + note({ phase: 'recreated', newWc: fresh.wcId !== first.wcId, notice: fresh.notice, ownerMoved: owner === win.webContents, oldDestroyed: destroyed.includes(first.wcId), windows: all.length, closeEvents: [...closeEvents] }); + + // Crash 3: give up. No reload, no new window; the stream keeps running in main. + // Both events fire in the same turn as the crash row, so wait for them BEFORE crashing. + const goneP = waitFor('gone'); const gaveP = waitFor('gaveUp'); + win.webContents.forcefullyCrashRenderer(); + const [gone3, g] = await Promise.all([goneP, gaveP]); + let reloadedAnyway = false; + try { await waitFor('ready', () => true, 3000); reloadedAnyway = true; } catch { /* expected: nothing comes back */ } + note({ phase: 'gave-up', streak: g.streak, reloadedAnyway, windows: BrowserWindow.getAllWindows().length }); + + finish({ + ok: true, + rows: [gone1, gone2, gone3].map((r) => ({ reason: r.reason, exitCode: r.exitCode, recovery: r.recovery, streak: r.streak, pid: r.pid, hasUptime: typeof r.windowUptimeMs === 'number' })), + firstPid + }); + } catch (e) { + finish({ ok: false, error: e.message }); + } +}); diff --git a/test/electron-harness/renderer-profile-main.cjs b/test/electron-harness/renderer-profile-main.cjs new file mode 100644 index 0000000000..52dedd142f --- /dev/null +++ b/test/electron-harness/renderer-profile-main.cjs @@ -0,0 +1,121 @@ +'use strict'; + +/** + * RENDERER-PROFILE (option A): real, never-shown windows driven by the REAL RendererProbe + * (transpiled from src). + * - BUSY: armed while healthy, then stuck in `runawayAllocator`, a loop that allocates without + * yielding (bounded at MAX_MB, then spins in the same function). capture() must name it (stack + * and profile), write a parseable .cpuprofile, and leave the renderer running. + * - UNARMED: the same loop, with the probe only arming at spike time (the 1.1.67 situation). + * capture() must time out at 'arm', give up and detach, and never hang main. + * - IDLE: an armed, calm page. No stack, a profile, and the page still answers afterwards. + * - FOREIGN: an armed page that hits a `debugger;` statement. The probe resumes it, so the page + * finishes its work. + * The result also records main's worst event-loop gap during each capture. userData and + * sessionData are inside the sandbox. + */ +const { app, BrowserWindow, ipcMain } = require('electron'); +const { isolateAppPaths } = require('./isolate-paths.cjs'); +const { writeFileSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const ts = require('typescript'); + +const MARKER = '__RENDERERPROFILE_RESULT__'; +const argOf = (n, d) => { const i = process.argv.indexOf(`--${n}`); return i >= 0 ? process.argv[i + 1] : d; }; +const sandbox = argOf('sandbox', null); +if (!sandbox) { process.stderr.write('renderer-profile harness: --sandbox required\n'); process.exit(2); } +// HARNESS-CRASHPAD: every path, crashDumps included, inside the sandbox (asserted). +isolateAppPaths(app, sandbox); + +const src = readFileSync(join(__dirname, '..', '..', 'src', 'main', 'rendererRecovery.ts'), 'utf8'); +const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; +const mod = { exports: {} }; +new Function('module', 'exports', 'require', js)(mod, mod.exports, require); +const R = mod.exports; + +const MAX_MB = 200; // bounded: never starves tests running beside it +const page = (name) => { + const file = join(sandbox, `${name}.html`); + writeFileSync(file, `${name}`); + return file; +}; + +const result = { ok: false }; +const finish = (extra = {}) => { Object.assign(result, extra); process.stdout.write(`${MARKER}${JSON.stringify(result)}\n`); app.exit(0); }; +setTimeout(() => finish({ error: 'timeout' }), 110_000).unref?.(); +app.on('window-all-closed', () => { /* each scenario opens its own window */ }); + +const waiters = new Map(); +const once = (ch) => new Promise((r) => { waiters.set(ch, r); }); +ipcMain.on('page:ready', (_e, name) => { const r = waiters.get(`ready:${name}`); if (r) r(); }); +ipcMain.on('page:after-debugger', () => { const r = waiters.get('after-debugger'); if (r) r(); }); + +/** main's worst event-loop gap while `fn` runs (a 20 ms ticker). */ +async function measured(fn) { + let last = Date.now(); let worst = 0; + const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 20); + try { const r = await fn(); return { r, worstGapMs: Math.max(worst, Date.now() - last) }; } finally { clearInterval(tick); } +} + +async function scenario(name, { arm = true, busy = false } = {}) { + const win = new BrowserWindow({ show: false, width: 400, height: 300, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false } }); + const wc = win.webContents; + const probe = new R.RendererProbe(wc.debugger, { devToolsOpen: () => wc.isDevToolsOpened() }); + const up = once(`ready:${name}`); + win.loadFile(page(name)); + await up; + const armed = arm ? await probe.arm() : false; + if (busy) { wc.send('page:busy'); await new Promise((r) => setTimeout(r, 700)); } + const pid = wc.getOSProcessId(); + const dir = join(sandbox, 'renderer-profiles'); + const { r, worstGapMs } = await measured(() => probe.capture({ write: (json) => R.saveRendererProfile(dir, pid, json) })); + let fileNodes = null; + if (r.file) { try { fileNodes = JSON.parse(readFileSync(r.file, 'utf8')).nodes.length; } catch { fileNodes = -1; } } + // Not left paused: an idle page must still answer; a busy one must still be allocating (alive). + const answers = busy ? null : await Promise.race([wc.executeJavaScript('window.ping()'), new Promise((res) => setTimeout(() => res('no-answer'), 30000))]); + const attachedAfter = wc.debugger.isAttached(); + const out = { name, armed, pid, profile: { ...r, file: r.file ? 'written' : null }, fileNodes, worstGapMs, answers, attachedAfter, foreignResumes: probe.foreignResumes }; + probe.giveUp(); + win.destroy(); + return out; +} + +async function foreign() { + const win = new BrowserWindow({ show: false, width: 400, height: 300, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false } }); + const wc = win.webContents; + const probe = new R.RendererProbe(wc.debugger, { devToolsOpen: () => wc.isDevToolsOpened() }); + const up = once('ready:foreign'); + win.loadFile(page('foreign')); + await up; + const armed = await probe.arm(); + const after = once('after-debugger'); + wc.send('page:debugger'); + const got = await Promise.race([after.then(() => 'continued'), new Promise((r) => setTimeout(() => r('stuck'), 30000))]); + const out = { name: 'foreign', armed, got, foreignResumes: probe.foreignResumes }; + probe.giveUp(); + win.destroy(); + return out; +} + +app.whenReady().then(async () => { + try { + const busy = await scenario('busy', { busy: true }); + const unarmed = await scenario('unarmed', { arm: false, busy: true }); + const idle = await scenario('idle'); + const dbgStmt = await foreign(); + finish({ ok: true, busy, unarmed, idle, foreign: dbgStmt }); + } catch (e) { + finish({ ok: false, error: e && e.stack ? e.stack : String(e) }); + } +}); diff --git a/test/electron-harness/run-ipc-order.cjs b/test/electron-harness/run-ipc-order.cjs new file mode 100644 index 0000000000..03d83a0368 --- /dev/null +++ b/test/electron-harness/run-ipc-order.cjs @@ -0,0 +1,14 @@ +'use strict'; + +/** Re-earn the published A15 ordering figure on demand: + * node test/electron-harness/run-ipc-order.cjs --phase1 2000 --phase2 5000 --phase3 60 */ +const { runIpcOrder } = require('./run.cjs'); + +const argOf = (n, d) => { + const i = process.argv.indexOf(`--${n}`); + return i >= 0 ? Number(process.argv[i + 1]) : d; +}; + +runIpcOrder({ phase1: argOf('phase1', 2000), phase2: argOf('phase2', 5000), phase3: argOf('phase3', 60) }) + .then((r) => { console.log(JSON.stringify(r, null, 2)); process.exit(r.ok ? 0 : 1); }) + .catch((e) => { console.error(e); process.exit(1); }); diff --git a/test/electron-harness/run.cjs b/test/electron-harness/run.cjs new file mode 100644 index 0000000000..912b11423e --- /dev/null +++ b/test/electron-harness/run.cjs @@ -0,0 +1,160 @@ +'use strict'; + +/** + * Node-side driver: run one scenario in a real Electron renderer and return its + * result, so a `node --test` file can assert on rendered evidence. + * + * The child is a real Electron process, so it is slower than the rest of the suite + * by roughly a second per scenario. That is the price of the evidence and is why + * these live in their own test file rather than being sprinkled through the + * existing ones. + */ +const { spawn } = require('node:child_process'); +const { existsSync, mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +/** + * THE SANDBOX LIFECYCLE IS OWNED HERE, BY THE PARENT. + * + * Each Electron child gets a throwaway `userData`. The child used to create it and delete + * it on the way out inside a swallowed `catch`; on Windows its own Chromium handles were + * still open, the delete failed, nothing said so, and about 1,400 directories / 14 GB built + * up in %TEMP%. So: the parent creates the directory, the child is TOLD where it is, and the + * parent removes it once the child has EXITED - retrying for a bounded time, because + * Windows releases a dead process's handles a moment after `close`. + * + * AND IT FAILS LOUDLY. A sandbox that still exists after the retries REJECTS the run with + * the path in the message. A silent leftover is the defect; a red test is the fix working. + */ +const SANDBOX_REMOVE_ATTEMPTS = 25; +const SANDBOX_REMOVE_WAIT_MS = 200; + +/** @param {string} prefix @param {string} [root] */ +function createSandbox(prefix, root = tmpdir()) { + return mkdtempSync(join(root, prefix)); +} + +/** Remove `dir`, retrying for a bounded time. Resolves when it is GONE; rejects, naming it, + * when it is not. @param {string} dir */ +async function removeSandbox(dir, attempts = SANDBOX_REMOVE_ATTEMPTS, waitMs = SANDBOX_REMOVE_WAIT_MS) { + let last = null; + for (let i = 0; i < attempts; i += 1) { + try { rmSync(dir, { recursive: true, force: true }); } catch (e) { last = e; } + if (!existsSync(dir)) return; + await new Promise((r) => setTimeout(r, waitMs)); + } + throw new Error(`HARNESS SANDBOX LEAK: could not remove ${dir} after ${attempts} attempts over ${attempts * waitMs} ms` + + `${last ? ` (${last.code || last.message})` : ''}. It is still on disk - remove it by hand, and find out what is holding it.`); +} + +/** Settle a child's result ONLY after its sandbox is gone; a leak outranks a result. */ +function settleAfterCleanup(sandbox, resolve, reject, settle) { + removeSandbox(sandbox).then(settle, (leak) => reject(leak)); +} + +const MARKER = '__HARNESS_RESULT__'; + +/** + * @param {string} scenario absolute path to a `.ts` scenario entry + * @param {{width?:number,height?:number,timeoutMs?:number}} [opts] + * @returns {Promise} whatever the scenario reported + */ +function runScenario(scenario, opts = {}) { + const electron = require('electron'); + const root = join(__dirname, '..', '..'); + const sandbox = createSandbox('l0-harness-', opts.tempRoot); + const args = [ + join(__dirname, 'harness-main.cjs'), + '--sandbox', sandbox, + '--scenario', scenario, + '--width', String(opts.width ?? 1280), + '--height', String(opts.height ?? 800), + '--timeout', String(opts.timeoutMs ?? 30_000) + ]; + + return new Promise((resolve, reject) => { + const child = spawn(electron, args, { + cwd: root, + env: { + ...process.env, + // Never let a harness run inherit the isolated-Dev identity or attach to + // the app's own sockets: this is a throwaway process, not an instance. + MUNDER_DEV: '', + ELECTRON_ENABLE_LOGGING: '1' + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { err += d.toString(); }); + + child.on('error', (e) => settleAfterCleanup(sandbox, resolve, reject, () => reject(e))); + child.on('close', (code) => settleAfterCleanup(sandbox, resolve, reject, () => { + const at = out.lastIndexOf(MARKER); + if (at < 0) { + reject(new Error( + `harness produced no result (exit ${code}).\n--- stdout ---\n${out}\n--- stderr ---\n${err}` + )); + return; + } + const line = out.slice(at + MARKER.length).split('\n')[0]; + try { + resolve(JSON.parse(line)); + } catch (e) { + reject(new Error(`harness result was not JSON: ${line}\n${e}`)); + } + })); + }); +} + +/** + * Run the A15 IPC-ordering experiment. + * + * THE COMMITTED TEST RUNS SMALL COUNTS ON PURPOSE. The published figure is 7,060 + * trials, and re-earning it takes minutes that the ordinary suite must not spend on + * every run. What belongs in the suite is that the INSTRUMENT still works and still + * finds no counterexample; the full figure is one command away and is named in the + * test, so the expensive run stays reproducible instead of becoming a quotation. + * + * node test/electron-harness/run-ipc-order.cjs --phase1 2000 --phase2 5000 --phase3 60 + */ +function runIpcOrder(opts = {}) { + const electron = require('electron'); + const root = join(__dirname, '..', '..'); + const sandbox = createSandbox('l0-ipcorder-', opts.tempRoot); + const args = [ + join(__dirname, 'ipc-order-main.cjs'), + '--sandbox', sandbox, + '--phase1', String(opts.phase1 ?? 200), + '--phase2', String(opts.phase2 ?? 500), + '--phase3', String(opts.phase3 ?? 5) + ]; + return new Promise((resolve, reject) => { + const child = spawn(electron, args, { + cwd: root, + env: { ...process.env, MUNDER_DEV: '' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { err += d.toString(); }); + child.on('error', (e) => settleAfterCleanup(sandbox, resolve, reject, () => reject(e))); + child.on('close', (code) => settleAfterCleanup(sandbox, resolve, reject, () => { + const at = out.lastIndexOf(MARKER); + if (at < 0) { + reject(new Error( + `ipc-order produced no result (exit ${code}).\n--- stdout ---\n${out}\n--- stderr ---\n${err}` + )); + return; + } + try { resolve(JSON.parse(out.slice(at + MARKER.length).split('\n')[0])); } + catch (e) { reject(e); } + })); + }); +} + +module.exports = { runScenario, runIpcOrder, createSandbox, removeSandbox }; diff --git a/test/electron-harness/scenarios/acquire-attach.ts b/test/electron-harness/scenarios/acquire-attach.ts new file mode 100644 index 0000000000..39f65552a7 --- /dev/null +++ b/test/electron-harness/scenarios/acquire-attach.ts @@ -0,0 +1,167 @@ +/** + * SCENARIO - L0-FUSION acquire-time detached attach. + * + * The stage's claim in one sentence: input provenance is wired for EVERY acquired + * terminal, including one that NO VIEW HAS EVER SHOWN, because xterm is opened at + * acquire time into a host that is not in the document. + * + * Before this stage a terminal only opened on first attach, so App.tsx's pre-warmed + * terminal-per-agent reported `inputOriginAttached: false` and could never become + * eligible however long it ran. That is the population this arm is about, so the + * terminal under test is deliberately never attached until the second arm. + * + * As in the stage-4 scenario the instrument is the production `terminalPool` with a + * bridge stub that RECORDS the origin of every `writePty` and every state report - the + * exact facts main would receive, read where they leave the renderer. + */ +import { + acquireTerminal, attachTerminal, disposeTerminal +} from '../../../src/renderer/src/components/terminalPool'; +import { isInputOriginAttached, inspectInputOrigin } from '../../../src/renderer/src/components/inputOrigin'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +interface Sent { id: string; data: string; origin: string } +const sent: Sent[] = []; +const reported: Array> = []; + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: unsub(), onPtyExit: unsub(), onPtyRelaunch: unsub(), + writePty: (id: string, data: string, origin: string) => { sent.push({ id, data, origin }); return Promise.resolve({ ok: true }); }, + redrawPty: () => { /* no pty */ }, resizePty: () => { /* no pty */ }, + copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve(), + reportTerminalInputState: (_id: string, state: unknown) => { + reported.push(state as Record); return Promise.resolve({ ok: true }); + } + }; +} + +const tick = () => new Promise((r) => { setTimeout(r, 0); }); +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); +const lastOrigin = () => sent.length ? sent[sent.length - 1].origin : null; +const key = (ta: HTMLTextAreaElement, k: string, code: number) => + ta.dispatchEvent(new KeyboardEvent('keydown', { key: k, code: k, keyCode: code, which: code, bubbles: true, cancelable: true } as KeyboardEventInit)); +/** Dispatch a key on xterm's helper textarea IF it has built one, and say whether it did. + * + * THE SCENARIO MUST NOT THROW BEFORE IT HAS REPORTED. Under the lifecycle this scenario + * kills - open() deferred to first attach - there is no element and no textarea at all + * before a view, so dispatching on it raises a TypeError, the whole run comes back as a + * bare `ok:false`, and the test fails at `r.ok` instead of at the named assertion that + * says WHICH property was lost. A mutant has to fail loudly in the right place: "the + * scenario threw" is not a diagnosis. So every fact is recorded first, and the keystroke + * is attempted defensively. (Dwight, follow-up finding 1.) */ +const keyIfPossible = (term: { textarea?: HTMLTextAreaElement }, k: string, code: number): boolean => { + const ta = term.textarea; + if (!ta) return false; + key(ta, k, code); + return true; +}; +const settle = async (pred: () => boolean, turns = 300): Promise => { + for (let i = 0; i < turns && !pred(); i++) await tick(); +}; + +window.__harnessRun = async () => { + const result: Record = {}; + try { + installBridgeStub(); + const root = document.getElementById('root')!; + + // ── ARM 1. Acquire ONLY. This terminal is never attached in this arm. ────── + // KILLS THE MUTANT that opens on first attach instead of at acquire: with that + // lifecycle nothing below exists - no element, no textarea, no listeners - so + // `openedAtAcquire` is false, the self-test never runs, and a keystroke is + // impossible. Every field in this arm is read while the host is OUT of the document. + const entry = acquireTerminal('unviewed'); + result.openedAtAcquire = entry.opened; + result.hostDetachedAtAcquire = entry.host.isConnected === false; + result.provenanceAttachedAtAcquire = isInputOriginAttached('unviewed'); + result.hasTextareaAtAcquire = !!entry.term.textarea; + + // The self-test runs to completion on a terminal that is still detached. + await settle(() => entry.inputSelfTest !== 'unknown'); + result.selfTestWhileDetached = entry.inputSelfTest; + result.selfTestLeakedBytes = sent.length; // NOT ONE BYTE on the wire + result.stillDetachedAfterSelfTest = entry.host.isConnected === false; + + // What MAIN was told about a terminal no view has ever shown. This is the fact the + // eligibility predicate consumes, so it is the one that matters for the stage. + await settle(() => reported.some((r) => r.selfTest === 'pass')); + const last = reported[reported.length - 1] ?? {}; + result.reportedAttached = last.inputOriginAttached; + result.reportedSelfTest = last.selfTest; + result.reportCount = reported.length; + + // A real keystroke on the detached textarea still classifies HUMAN... + sent.length = 0; + result.detachedKeyDispatched = keyIfPossible(entry.term, 'ArrowRight', 39); + result.detachedKeyOrigin = lastOrigin(); + result.detachedKeyData = sent.length ? sent[sent.length - 1].data : null; + + // ...and a protocol reply while detached is still CONTROL, not a human byte. + // The keystroke above opened the same-tick human window, and it closes in a + // microtask, so we let it close FIRST and record that it really did. Without this + // the reply would ride that still-open window and read HUMAN - correctly, since a + // byte emitted inside a live human dispatch is human by construction. Measuring + // CONTROL only means anything once the window is shut, which is why the production + // self-test also closes it before its own control half. + await tick(); + result.windowClosedBeforeReply = isInputOriginAttached('unviewed') + && inspectInputOrigin('unviewed')?.sameTick === false; + sent.length = 0; + await write(entry.term, '\x1b[6n'); + result.detachedReplyOrigin = lastOrigin(); + + // ── ARM 2. Now attach it. The open guard must hold. ─────────────────────── + // KILLS THE MUTANT that relaxes the `opened` guard (or re-opens on attach): a second + // open() recreates term.element and ORPHANS the provenance listeners, with no error + // and no symptom except that human input silently stops being seen. The identity + // check and the single-emission count below are what that mutant breaks. + const elementBefore = entry.term.element; + const genBefore = entry.generation; + const unsubBefore = entry.unsub.length; + attachTerminal(entry, root); + result.elementSameAfterAttach = entry.term.element === elementBefore; + result.generationUnchangedByAttach = entry.generation === genBefore; + result.noExtraListenerOnAttach = entry.unsub.length === unsubBefore; + result.hostConnectedAfterAttach = entry.host.isConnected === true; + + sent.length = 0; + result.attachedKeyDispatched = keyIfPossible(entry.term, 'ArrowRight', 39); + result.attachedKeyOrigin = lastOrigin(); + result.attachedKeyEmissions = sent.length; // exactly 1: no duplicated listener + + // ── OBSERVATION, NOT AN ASSERTION: the grid this terminal proposes. ────── + // The accepted cost of opening detached is one column: xterm's Viewport measures the + // scrollbar ONCE in its constructor (Viewport.ts:70), so a detached-opened terminal + // keeps the 15px fallback for life where an attached-opened one measures the real bar. + // Recorded here as a number for the record, NOT asserted - the delta is the platform's + // scrollbar width and this must not become a Windows-only test. NO CONTROL IS TAKEN + // HERE, and deliberately so: after this stage EVERY pooled terminal opens detached at + // acquire, so the pool can no longer produce an attached-opened control at all. The + // controlled side-by-side measurement that priced this column lives in the + // detached-open scenario, which was written against the pre-stage lifecycle. + const sized = document.createElement('div'); + sized.style.width = '640px'; sized.style.height = '240px'; + root.appendChild(sized); + attachTerminal(entry, sized); + try { entry.fit.fit(); } catch { /* unsized */ } + result.colsAfterFit = entry.term.cols; + result.rowsAfterFit = entry.term.rows; + + disposeTerminal('unviewed'); + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/activity-renders.tsx b/test/electron-harness/scenarios/activity-renders.tsx new file mode 100644 index 0000000000..132f6b64c4 --- /dev/null +++ b/test/electron-harness/scenarios/activity-renders.tsx @@ -0,0 +1,80 @@ +/** + * SCENARIO - ACTIVITY-LAG-151. HOW MANY RENDERS DOES "NOTHING CHANGED" COST? + * + * Measured by Jim on 1.1.50: every output chunk of the agent you watch, and every hook or + * statusline tick of every agent, re-rendered the WHOLE app, because `updateAgent` built a + * new `agents` array even for a patch that changed nothing (300 whole-App renders for 300 + * chunks, ~1.2-1.9 ms each on the renderer's only thread). + * + * This mounts, with the PRODUCTION store and the PRODUCTION `usePtyParser`, a component + * that subscribes to `agents` exactly as App does, and counts its renders: + * A. N no-op `updateAgent` patches (what a hook or statusline tick repeats) -> renders. + * B. N running-turn chunks through the real parser for an agent ALREADY working -> + * renders, and how many `updateAgent` calls the parser made at all. + * C. one real change -> exactly one render (the probe is live). + * D. the parser on an IDLE agent -> exactly one transition write. + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; +import { usePtyParser } from '../../../src/renderer/src/hooks/usePtyParser'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => (name.startsWith('on') ? () => () => {} : name.endsWith('Sync') ? () => null : () => Promise.resolve(undefined)) +}); + +const N = 50; +let renders = 0; +let parse: ((chunk: string) => void) | null = null; +function Probe() { useStore((s) => s.agents); renders += 1; return null; } +function Parser() { parse = usePtyParser('a1'); return null; } + +window.__harnessRun = async () => { + try { + // Count every updateAgent call (the parser reads it from the store at render). + const real = useStore.getState().updateAgent; + let updateCalls = 0; + useStore.setState({ updateAgent: (id: string, patch: Partial) => { updateCalls += 1; real(id, patch); } } as never); + useStore.setState({ agents: [{ id: 'a1', name: 'Alice', status: 'working', action: 'reading', progress: 0 } as unknown as Agent] } as never); + + const root = createRoot(document.getElementById('root')!); + await act(async () => { root.render(<>); }); + const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + // A. no-op patches + let r0 = renders; + await act(async () => { for (let i = 0; i < N; i++) useStore.getState().updateAgent('a1', { status: 'working' }); }); + await settle(); + const noOpRenders = renders - r0; + + // B. running chunks for an agent already working + r0 = renders; const c0 = updateCalls; + await act(async () => { for (let i = 0; i < N; i++) parse!(`✶ Thinking… (${i}s · esc to interrupt)\r\n`); }); + await settle(); + const parserRenders = renders - r0; const parserWrites = updateCalls - c0; + + // C. a real change is still rendered, exactly once + r0 = renders; + await act(async () => { useStore.getState().updateAgent('a1', { action: 'writing' }); }); + await settle(); + const realChangeRenders = renders - r0; + + // D. an idle agent starting a turn: exactly one transition write + await act(async () => { useStore.getState().updateAgent('a1', { status: 'idle' }); }); + const c1 = updateCalls; + await act(async () => { for (let i = 0; i < N; i++) parse!(`✶ Thinking… (${i}s · esc to interrupt)\r\n`); }); + await settle(); + const transitionWrites = updateCalls - c1; + const statusAfter = useStore.getState().agents.find((a) => a.id === 'a1')?.status; + + window.harness.report({ ok: true, N, noOpRenders, parserRenders, parserWrites, realChangeRenders, transitionWrites, statusAfter }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e) }); + } +}; diff --git a/test/electron-harness/scenarios/app-recovery.tsx b/test/electron-harness/scenarios/app-recovery.tsx new file mode 100644 index 0000000000..c1ca260761 --- /dev/null +++ b/test/electron-harness/scenarios/app-recovery.tsx @@ -0,0 +1,66 @@ +/** + * SCENARIO - RR-164 (Jim finding 1), on the REAL App: a view that main brought back after a + * renderer crash (preload `recovering` = true) must open on the live floor with the "restored" + * notice, NOT on the launch-time HivePicker (whose switch path would tear down live agents). + * The control mount (recovering = false, a normal launch) still shows the picker. + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { App } from '../../../src/renderer/src/App'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} + +(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = '1.1.63'; +let recovering = false; +const notice = { at: Date.now(), action: 'reload', reason: 'crashed', streak: 1 }; +const config = { onboardingComplete: true, harnessHome: 'C:/harness-test-home', recentHomes: [] }; +const cthCalls: string[] = []; +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => { + if (name === 'recovering') return recovering; + if (name === 'platform') return 'win32'; + if (name === 'arch') return 'x64'; + cthCalls.push(name); + if (name === 'getConfig') return () => Promise.resolve({ ...config }); + if (name === 'takeRecoveryNotice') return () => Promise.resolve(recovering ? notice : null); + if (name === 'listPtys') return () => Promise.resolve([]); + if (name.startsWith('on')) return () => () => {}; + if (name.endsWith('Sync')) return () => null; + return () => Promise.resolve(undefined); + } +}); + +const settle = (ms = 300) => act(async () => { await new Promise((r) => setTimeout(r, ms)); }); + +async function mount(flag: boolean) { + recovering = flag; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { root.render(); }); + await settle(600); + const text = host.textContent ?? ''; + const res = { + picker: text.includes('Open the one you were working in'), + notice: !!host.querySelector('[data-testid="recovery-notice"]'), + noticeText: host.querySelector('[data-testid="recovery-notice"]')?.textContent ?? null + }; + await act(async () => { root.unmount(); }); + host.remove(); + return res; +} + +window.__harnessRun = async () => { + try { + const recovered = await mount(true); + const normal = await mount(false); + window.harness.report({ ok: true, recovered, normal, askedForNotice: cthCalls.includes('takeRecoveryNotice') }); + } catch (e) { + window.harness.report({ ok: false, error: e instanceof Error ? `${e.message}\n${e.stack}` : String(e) }); + } +}; diff --git a/test/electron-harness/scenarios/auto-submit-fusion.ts b/test/electron-harness/scenarios/auto-submit-fusion.ts new file mode 100644 index 0000000000..8ca3751a70 --- /dev/null +++ b/test/electron-harness/scenarios/auto-submit-fusion.ts @@ -0,0 +1,258 @@ +/** + * SCENARIO - L0-FUSION stage 5.4c. THE FUSED TRANSACTION AGAINST A REAL XTERM. + * + * The node suites prove the owner against a world of doubles. What they cannot show is + * that the facts the owner decides on really ARRIVE from a rendered terminal: that a + * genuine keydown is classified HUMAN at the place bytes leave the renderer, that a + * cursor-position reply is not, that the prompt mirror really says "draft", and that the + * erase oracle really reads the rendered prompt row. This scenario bundles the + * PRODUCTION pieces on both sides of the IPC line into one page and lets them run: + * + * REAL AutomaticSubmitOwner, buildOwnerDeps, ScreenReadingBroker (src/main, pure) + * REAL terminalPool + inputOrigin + xterm: classification, the prompt mirror, the + * input-state mirror, the screen-read responder, readScreenForNeedle + * REAL timers. GAP_MS is a real 140ms and the human's key lands inside it. + * + * DOUBLE the PTY PROCESS: a line-editing echo TUI (Ctrl-U kills the line, Enter submits). + * DOUBLE `pty.ts`'s ACCOUNTING, which cannot load here (node-pty): a declared-HUMAN + * write advances the generation and stamps lastHumanInputAt; PROGRAMMATIC and + * CONTROL do not. That is the OwnerPty contract restated in nine lines, and it + * is NOT evidence about pty.ts itself - test/input-provenance.test.cjs is. + * DOUBLE capacity: ALLOW, with a switch for a late REFUSE. + * The IPC hop is a function call. Channel wiring is pinned statically elsewhere. + * + * EVERY ARM HAS A PAIRED CONTROL that differs in exactly the fact under test and ends the + * other way, so no outcome here can be the scenario's own default: + * ctl (nobody types -> COMMITTED) vs int (a real key in the gap -> INTERFERED) + * int (a HUMAN key -> INTERFERED) vs cpr (a CPR reply in the gap -> COMMITTED) + * abt (Ctrl-U honoured -> ABORTED) vs abn (Ctrl-U ignored -> INTERFERED, no Enter) + * ctl (free prompt -> typed) vs drf (human draft mirrored -> REFUSED, nothing typed) + */ +import { + acquireTerminal, attachTerminal, readScreenForNeedle +} from '../../../src/renderer/src/components/terminalPool'; +import { AutomaticSubmitOwner, GAP_MS, type SubmitOutcome } from '../../../src/main/automaticSubmit'; +import { buildOwnerDeps, ScreenReadingBroker } from '../../../src/main/automaticSubmitWiring'; +import { isTerminalPromptState, type TerminalPromptState } from '../../../src/shared/promptState'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +interface LogEntry { via: 'OWNER' | 'BRIDGE'; data: string; origin: string } + +/** The PTY process double: a line editor that echoes, kills the line on Ctrl-U (unless + * told to ignore it), submits on Enter and swallows escape sequences sent TO it. */ +class EchoTui { + line = ''; + submitted: string[] = []; + ignoreClear = false; + emit: (chunk: string) => void = () => { /* not attached yet */ }; + onPayload: (() => void) | null = null; + boot(): void { this.emit('\x1b[2J\x1b[Hecho tui\r\n> '); } + input(data: string): void { + const text = data.replace(/\x1b\[20[01]~/g, ''); + if (text.startsWith('\x1b')) return; // a terminal reply (CPR, focus): not line input + for (const ch of text) { + if (ch === '\r') { this.submitted.push(this.line); this.line = ''; this.emit('\r\n> '); } + else if (ch === '\x15') { if (!this.ignoreClear) { this.line = ''; this.emit('\r\x1b[2K> '); } } + else { this.line += ch; this.emit(ch); } + } + if (text.length > 3 && this.onPayload) { const f = this.onPayload; this.onPayload = null; f(); } + } +} + +interface Pty { + tui: EchoTui; generation: number; lastHumanInputAt: number | undefined; + inputState: unknown; promptState: TerminalPromptState | undefined; promptBlocks: unknown[]; + log: LogEntry[]; +} +const ptys = new Map(); +const pty = (id: string): Pty => { + let p = ptys.get(id); + if (!p) { + p = { tui: new EchoTui(), generation: 0, lastHumanInputAt: undefined, inputState: undefined, promptState: undefined, promptBlocks: [], log: [] }; + ptys.set(id, p); + } + return p; +}; + +let screenReadListener: ((req: { requestId: string; ptyId: string; needle: string }) => void) | null = null; +let screenAnswers = 0; +const broker = new ScreenReadingBroker((ptyId, requestId, needle) => { + if (!screenReadListener) return false; + screenReadListener({ requestId, ptyId, needle }); + return true; +}); + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: (id: string, cb: (chunk: string) => void) => { pty(id).tui.emit = cb; return () => { /* kept */ }; }, + onPtyExit: unsub(), onPtyRelaunch: unsub(), + // `pty.ts`'s accounting, restated (see the header): HUMAN advances the generation. + writePty: (id: string, data: string, origin: string) => { + const p = pty(id); + p.log.push({ via: 'BRIDGE', data, origin }); + if (origin === 'HUMAN') { p.generation += 1; p.lastHumanInputAt = Date.now(); } + p.tui.input(data); + return Promise.resolve({ ok: true }); + }, + redrawPty: () => { /* no pty */ }, resizePty: () => { /* no pty */ }, + copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve(), + reportTerminalInputState: (id: string, state: unknown) => { pty(id).inputState = state; return Promise.resolve({ ok: true }); }, + // Validated exactly as main's handler validates it; a malformed report is dropped. + reportTerminalPromptState: (id: string, state: unknown) => { + if (!isTerminalPromptState(state)) return Promise.resolve({ ok: false }); + pty(id).promptState = state; pty(id).promptBlocks.push(state.block); + return Promise.resolve({ ok: true }); + }, + onScreenReadRequest: (cb: typeof screenReadListener) => { screenReadListener = cb; return () => { screenReadListener = null; }; }, + answerScreenReading: (requestId: string, reading: unknown) => { screenAnswers += 1; broker.answer(requestId, reading); } + }; +} + +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); +/** FLAKY-TIMING (Andy, flaky-170): a poll's deadline is a hang guard, so it is generous - a + * mirror that never arrives still fails, at the deadline, and a busy renderer cannot. */ +const MIRROR_DEADLINE_MS = 30_000; +async function until(what: () => boolean, ms: number): Promise { + const end = Date.now() + ms; + while (Date.now() < end) { if (what()) return true; await sleep(20); } + return what(); +} +/** A GENUINE KEYDOWN on the terminal's own textarea. xterm turns a printable keydown into + * data by itself; a keypress as well would type the character twice (measured). */ +function pressKey(ta: HTMLTextAreaElement, ch: string): void { + const code = ch.toUpperCase().charCodeAt(0); + ta.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: `Key${ch.toUpperCase()}`, keyCode: code, which: code, bubbles: true, cancelable: true } as KeyboardEventInit)); +} + +let lateRefuse = false; +const owner = new AutomaticSubmitOwner(buildOwnerDeps({ + pty: { + write: (id, data, origin) => { const p = pty(id); p.log.push({ via: 'OWNER', data, origin }); p.tui.input(data); return { ok: true }; }, + incarnation: (id) => (ptys.has(id) ? 1 : undefined), + humanInputGeneration: (id) => ptys.get(id)?.generation, + lastHumanInputAt: (id) => ptys.get(id)?.lastHumanInputAt, + hasOutput: (id) => (ptys.has(id) ? true : undefined), + inputState: (id) => ptys.get(id)?.inputState as never, + promptState: (id) => ptys.get(id)?.promptState + }, + capacity: { + admit: (_agentId, workClass) => ({ verdict: 'ALLOW', reason: 'AVAILABLE', poolKey: 'pool', state: null, workClass, limitEpochAt: null, grantId: null }) as never, + revalidate: () => (lateRefuse ? { verdict: 'REFUSE', reason: 'LIMITED' } : { verdict: 'ALLOW', reason: 'AVAILABLE' }) as never, + confirmLaunch: () => { /* nothing to confirm */ }, + cancelGrant: () => { /* nothing to cancel */ }, + holdGrant: () => { /* nothing to hold */ } + }, + ptyForAgent: (agentId) => agentId.replace(/^agent-/, ''), + providerForPty: () => 'claude', + requestScreenReading: (ptyId, needle) => broker.request(ptyId, needle) +})); + +const root = () => document.getElementById('root')!; +async function open(id: string): Promise<{ ta: HTMLTextAreaElement; rowText: () => string; ready: boolean }> { + const entry = acquireTerminal(id); + attachTerminal(entry, root()); + pty(id).tui.boot(); + // Both mirrors must have ARRIVED through the production path before anything is asked. + const ready = await until(() => pty(id).inputState !== undefined && pty(id).promptState !== undefined && entry.inputSelfTest !== 'unknown', MIRROR_DEADLINE_MS); + pty(id).log.length = 0; // the self-test's own bytes are not part of any arm + const rowText = () => { + const buf = entry.term.buffer.active; + return buf.getLine(buf.baseY + buf.cursorY)?.translateToString(true) ?? ''; + }; + return { ta: entry.term.textarea!, rowText, ready }; +} +const submit = (id: string, requestId: string, text: string, admissionClass: 'CAPACITY_GATED' | 'USER_RELEASED' = 'CAPACITY_GATED'): Promise => + owner.submit({ requestId, agentId: `agent-${id}`, admissionClass, text, settleMs: 0 }); +const ownerWrites = (id: string) => pty(id).log.filter((e) => e.via === 'OWNER').map((e) => e.data); + +window.__harnessRun = async () => { + const result: Record = { gapMs: GAP_MS }; + try { + installBridgeStub(); + + // ── ctl: nobody types. The baseline every other arm is measured against. ────────── + { + const t = await open('ctl'); + const outcome = await submit('ctl', 'r-ctl', 'control message one'); + result.ctl = { ready: t.ready, outcome, ownerWrites: ownerWrites('ctl'), submitted: pty('ctl').tui.submitted, + promptBlocksBefore: pty('ctl').promptBlocks.slice(), inputState: pty('ctl').inputState }; + } + + // ── int: A GENUINE KEY IN THE GAP. ──────────────────────────────────────────────── + { + const t = await open('int'); + pty('int').tui.onPayload = () => { setTimeout(() => pressKey(t.ta, 'x'), 30); }; + const outcome = await submit('int', 'r-int', 'interfered message two'); + await sleep(50); + const writesAtOutcome = ownerWrites('int'); + const again = await submit('int', 'r-int-2', 'a later automatic message'); + const manual = await submit('int', 'r-int-3', 'a later send-now message', 'USER_RELEASED'); + const lineBeforeResolve = pty('int').tui.line; + // There is no default resolution: a bare "resolved" is refused and the hold stays. + const bare = (owner.resolveInterference as (p: string, how?: unknown) => boolean)('int'); + const stillHeld = owner.inhibition('int') !== null; + const resolved = owner.resolveInterference('int', 'SEND_AGAIN'); + const afterResolve = await submit('int', 'r-int-4', 'after the human resolved'); + result.int = { + ready: t.ready, outcome, writesAtOutcome, + humanBytes: pty('int').log.filter((e) => e.via === 'BRIDGE' && e.origin === 'HUMAN').map((e) => e.data), + promptRow: t.rowText(), oracle: readScreenForNeedle('int', 'interfered message two'), + submitted: pty('int').tui.submitted, again, manual, bare, stillHeld, resolved, + lineBeforeResolve, lineAfterResolve: pty('int').tui.line, + afterResolve, ownerWritesAtEnd: ownerWrites('int'), + inhibitedAtEnd: owner.inhibition('int') !== null + }; + } + + // ── cpr: A CURSOR-POSITION REPLY IN THE GAP is the terminal talking, not a person. ─ + { + const t = await open('cpr'); + pty('cpr').tui.onPayload = () => { setTimeout(() => pty('cpr').tui.emit('\x1b[6n'), 30); }; + const outcome = await submit('cpr', 'r-cpr', 'cpr message three'); + const log = pty('cpr').log; + const payloadAt = log.findIndex((e) => e.via === 'OWNER' && e.data.includes('cpr message three')); + const enterAt = log.findIndex((e) => e.via === 'OWNER' && e.data === '\r'); + const replyAt = log.findIndex((e) => e.via === 'BRIDGE' && /^\x1b\[\d+;\d+R$/.test(e.data)); + result.cpr = { ready: t.ready, outcome, payloadAt, replyAt, enterAt, replyOrigin: replyAt >= 0 ? log[replyAt].origin : null, + submitted: pty('cpr').tui.submitted, generation: pty('cpr').generation }; + } + + // ── abt / abn: a late refusal. The erase is VERIFIED ON THE RENDERED SCREEN. ────── + for (const id of ['abt', 'abn']) { + const t = await open(id); + pty(id).tui.ignoreClear = id === 'abn'; + const answersBefore = screenAnswers; + pty(id).tui.onPayload = () => { lateRefuse = true; }; + const outcome = await submit(id, `r-${id}`, `late refusal ${id} four`); + lateRefuse = false; + result[id] = { ready: t.ready, outcome, ownerWrites: ownerWrites(id), submitted: pty(id).tui.submitted, + promptRow: t.rowText(), oracle: readScreenForNeedle(id, `late refusal ${id} four`), + screenAnswers: screenAnswers - answersBefore, inhibited: owner.inhibition(id) !== null }; + } + + // ── drf: THE PROMPT MIRROR, END TO END. A human's draft refuses automatic typing. ─ + { + const t = await open('drf'); + pressKey(t.ta, 'h'); pressKey(t.ta, 'i'); + const mirrored = await until(() => pty('drf').promptState?.block === 'draft', MIRROR_DEADLINE_MS); + await sleep(1_700); // past HUMAN_QUIET_MS, so the DRAFT - not recency - is what refuses + const outcome = await submit('drf', 'r-drf', 'must not be typed five'); + result.drf = { ready: t.ready, mirrored, promptBlocks: pty('drf').promptBlocks, outcome, + ownerWrites: ownerWrites('drf'), line: pty('drf').tui.line, promptRow: t.rowText() }; + } + + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/composer-bridge-stub.ts b/test/electron-harness/scenarios/composer-bridge-stub.ts new file mode 100644 index 0000000000..3949ebcea7 --- /dev/null +++ b/test/electron-harness/scenarios/composer-bridge-stub.ts @@ -0,0 +1,34 @@ +/** + * The bridge the composer scenario runs against. IMPORTED FIRST, so `window.cth` exists + * before any renderer module that touches it at load is evaluated. + * + * `controlSnapshot` answers from `bridge.snapshot`, which the scenario changes between + * steps exactly as main's state would change. `resolveInterference` RECORDS the call and + * clears the hold, as the owner does. Everything else is inert: an `on*` subscription + * returns an unsubscribe, any other call resolves undefined. + */ +export const bridge = { + snapshot: {} as Record, + resolveCalls: [] as Array<{ agentId: string; how: unknown; at: number }>, + /** When true main REFUSES the resolution (returns false) and the hold stays. */ + refuse: false, + snapshotReads: 0 +}; + +const known: Record = { + controlSnapshot: (_agentId: string) => { bridge.snapshotReads += 1; return Promise.resolve({ ...bridge.snapshot }); }, + resolveInterference: (agentId: string, how: unknown) => { + bridge.resolveCalls.push({ agentId, how, at: Date.now() }); + if (bridge.refuse) return Promise.resolve(false); + bridge.snapshot = { ...bridge.snapshot, interfered: null }; + return Promise.resolve(true); + } +}; + +(window as unknown as { cth: unknown }).cth = new Proxy(known, { + get: (target, name: string) => target[name] + ?? (name.startsWith('on') ? () => () => { /* nothing subscribed */ } + // A `*Sync` read answers a VALUE (the store reads `rosterReadSync()` at load): null. + : name.endsWith('Sync') ? () => null + : () => Promise.resolve(undefined)) +}); diff --git a/test/electron-harness/scenarios/composer-interfered.tsx b/test/electron-harness/scenarios/composer-interfered.tsx new file mode 100644 index 0000000000..7e8c4beecd --- /dev/null +++ b/test/electron-harness/scenarios/composer-interfered.tsx @@ -0,0 +1,150 @@ +/** + * SCENARIO - L0-FUSION stage 5.4d. THE COMPOSER, RENDERED: a held queue says why, and + * "resolved" is a real button that a real click - and nothing else - acts on. + * + * The PRODUCTION `MessageQueueComposer` is mounted with React into a real page, against + * the production store. The only double is the preload bridge (composer-bridge-stub.ts): + * `controlSnapshot` answers what main would answer, and `resolveInterference` records + * that it was called. Clicks are TRUSTED input events sent through Chromium at the + * button's rendered coordinates (harness `click`), so a button that is covered, off-screen + * or not rendered cannot be "clicked" the way `element.click()` would click it. + * + * It proves what is on the page and what a click does. It does not prove main's side of + * the IPC (test/delivery-hold.test.cjs pins the handler; the fused scenario runs the owner). + */ +import { bridge } from './composer-bridge-stub'; +import { createRoot } from 'react-dom/client'; +import { MessageQueueComposer } from '../../../src/renderer/src/components/MessageQueueComposer'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void; click: (x: number, y: number) => Promise; axTree: () => Promise }; + } +} + +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); +async function until(what: () => boolean, ms: number): Promise { + const end = Date.now() + ms; + while (Date.now() < end) { if (what()) return true; await sleep(25); } + return what(); +} +const buttons = (label: string) => [...document.querySelectorAll('button')].filter((b) => b.textContent?.trim() === label); +const pageText = () => document.body.innerText; +const titleOfHint = () => [...document.querySelectorAll('span[title]')].map((s) => s.getAttribute('title') ?? '').find((t) => t.includes('Alice')) ?? null; +const rows = () => useStore.getState().messageQueues.a1?.map((m) => ({ id: m.id, manual: !!m.manual })) ?? []; + +let trusted: boolean[] = []; +async function realClick(el: Element): Promise<{ x: number; y: number; hit: boolean }> { + const r = el.getBoundingClientRect(); + const x = r.left + r.width / 2; const y = r.top + r.height / 2; + // What is ACTUALLY rendered at that point. A covered button is not this element. + const hit = document.elementFromPoint(x, y) === el; + await window.harness.click(x, y); + return { x, y, hit }; +} + +window.__harnessRun = async () => { + const result: Record = {}; + try { + document.addEventListener('click', (e) => { trusted.push(e.isTrusted); }, true); + const agent = { id: 'a1', name: 'Alice', status: 'idle' } as unknown as Agent; + useStore.setState({ messageQueues: { a1: [ + { id: 'm1', text: 'the message that was typed over', ts: 1 }, + { id: 'm2', text: 'a later message', ts: 2 }, + { id: 'm3', text: 'a third message', ts: 3 } + ] } } as never); + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: true, capacityEvidence: 'LIMITED_NO_KNOWN_RESET', + interfered: { requestId: 'queue:a1:m1', reason: 'HUMAN_INPUT_AFTER_STAGE', at: 1 } }; + // Mounted as the app mounts it: at the foot of a column, under the terminal it serves + // (AgentDetailPanel / CommandCenterPanel / FullscreenTerminal). Since LAG-150 the pending + // list is an overlay rising over that terminal, so a composer mounted alone at the very + // top of the page would push its own list above the viewport, a layout no user has. + createRoot(document.getElementById('root')!).render( +
+
+ +
+ ); + + const SEND = 'send queued message'; const DROP = 'already handled — drop'; + const heldTags = () => (pageText().match(/held — typed over, not submitted/g) ?? []).length; + const calls = () => bridge.resolveCalls.map((c) => [c.agentId, c.how]); + const hold = (requestId: string) => { bridge.snapshot = { ...bridge.snapshot, interfered: { requestId, reason: 'HUMAN_INPUT_AFTER_STAGE', at: 1 } }; }; + + // ── STEP 1: INTERFERED is on the page, as TWO actions. ───────────────────────────── + const shown = await until(() => buttons(SEND).length === 1 && buttons(DROP).length === 1, 4_000); + const ax = await window.harness.axTree(); + const axNames = Array.isArray(ax) ? ax.filter((n: { role: string; ignored: boolean }) => n.role === 'button' && !n.ignored).map((n: { name: string }) => n.name) : []; + result.interfered = { + shown, text: pageText(), title: titleOfHint(), + sendTitle: buttons(SEND)[0]?.getAttribute('title'), dropTitle: buttons(DROP)[0]?.getAttribute('title'), + resolvedButtons: buttons('resolved').length, sendNowButtons: buttons('send now').length, heldTags: heldTags(), + axSend: axNames.filter((n: string) => n === SEND).length, axDrop: axNames.filter((n: string) => n === DROP).length + }; + + // ── STEP 2: NOTHING BUT A CLICK RESOLVES IT. Two full poll cycles, no click. ──── + await sleep(4_500); + result.unattended = { resolveCalls: bridge.resolveCalls.length, snapshotReads: bridge.snapshotReads, stillShown: buttons(DROP).length === 1 }; + + // ── STEP 3a: main REFUSES the resolution -> nothing is dropped. ───────────────────── + bridge.refuse = true; + await realClick(buttons(DROP)[0]); + await sleep(300); + result.refused = { rows: rows(), calls: calls(), stillShown: buttons(DROP).length === 1 }; + bridge.refuse = false; bridge.resolveCalls.length = 0; + + // ── STEP 3b: a REAL click on "already handled - drop": THAT row goes, no other. ─ + trusted = []; + const dropClick = await realClick(buttons(DROP)[0]); + const dropGone = await until(() => buttons(DROP).length === 0, 4_000); + result.dropped = { click: dropClick, trusted: trusted.slice(), calls: calls(), gone: dropGone, rows: rows() }; + + // ── STEP 3c: the NEXT message is interfered with; "send queued message" keeps it. ─ + bridge.resolveCalls.length = 0; + hold('queue:a1:m2'); + await until(() => buttons(SEND).length === 1, 4_000); + const heldTagsOnM2 = heldTags(); + trusted = []; + const sendClick = await realClick(buttons(SEND)[0]); + const sendGone = await until(() => buttons(SEND).length === 0, 4_000); + result.sentAgain = { click: sendClick, trusted: trusted.slice(), calls: calls(), gone: sendGone, rows: rows(), heldTagsOnM2 }; + + // ── STEP 3d: a held WAKE (not a queue item): other words, and no row is touched. ─ + bridge.resolveCalls.length = 0; + hold('wake:a1:9'); + await until(() => buttons('let it retry').length === 1, 4_000); + result.wake = { labels: ['let it retry', 'already handled', SEND, DROP].map((l) => buttons(l).length), heldTags: heldTags(), + retryTitle: buttons('let it retry')[0]?.getAttribute('title') }; + await realClick(buttons('already handled')[0]); + await until(() => buttons('already handled').length === 0, 4_000); + result.wakeResolved = { calls: calls(), rows: rows() }; + + // ── STEP 4: what remains is the ENDLESS capacity hold, and "send now" is the way out. ─ + bridge.resolveCalls.length = 0; + const offered = await until(() => buttons('send now').length === 2, 4_000); + result.capacity = { offered, text: pageText(), title: titleOfHint(), sendNowButtons: buttons('send now').length, + heldTags: (pageText().match(/held — typed over, not submitted/g) ?? []).length }; + trusted = []; + const click2 = await realClick(buttons('send now')[1]); + await until(() => rows()[0]?.id === 'm3', 2_000); + result.released = { click: click2, trusted: trusted.slice(), rows: rows(), resolveCalls: bridge.resolveCalls.length }; + + // ── STEP 4b: the spent post-reset probe - usually ends by itself, cannot be promised to. ─ + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: true, capacityEvidence: 'POST_RESET_PROBE_SPENT', interfered: null }; + useStore.setState({ messageQueues: { a1: [{ id: 'm9', text: 'waiting on a probe', ts: 9 }] } } as never); + const probeShown = await until(() => /waiting for a new capacity reading/.test(pageText()), 4_000); + result.spentProbe = { shown: probeShown, text: pageText(), title: titleOfHint(), sendNowButtons: buttons('send now').length }; + + // ── STEP 5: no pool. The queue moves, and it is NOT called available. ─────────── + useStore.setState({ messageQueues: { a1: [{ id: 'm3', text: 'moving', ts: 3 }] } } as never); + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: false, capacityEvidence: 'NO_POOL', interfered: null }; + const noted = await until(() => /outside capacity gating/.test(pageText()), 4_000); + result.noPool = { noted, text: pageText(), sendNowButtons: buttons('send now').length }; + + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/detail-panel-error-overlay.tsx b/test/electron-harness/scenarios/detail-panel-error-overlay.tsx new file mode 100644 index 0000000000..d4d0371611 --- /dev/null +++ b/test/electron-harness/scenarios/detail-panel-error-overlay.tsx @@ -0,0 +1,90 @@ +/** + * SCENARIO - CODEX-REDRAW-151 R-1 (Jim's audit). The open-terminal error floats in a + * zero-height anchor so it cannot resize the terminal. It floats directly over the operator + * controls, so it must (a) never take their clicks and (b) clear on its own. + * + * Mounts the PRODUCTION AgentDetailPanel for a live worker at the default 420 px sidebar + * width, makes "open terminal" fail, and reports: what elementFromPoint hits at the top, + * centre and bottom of every control-strip button while the error shows; the terminal + * area's top edge before, during and after; and whether the error is gone after its 4 s. + * Adapted from Jim's audit probe (codex151-audit/zz-jim-error-overlay.tsx). + */ +import { bridge } from './composer-bridge-stub'; +import { createRoot } from 'react-dom/client'; +import { AgentDetailPanel } from '../../../src/renderer/src/components/AgentDetailPanel'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; + +declare global { interface Window { __harnessRun: () => Promise; harness: { report: (p: unknown) => void; click: (x: number, y: number) => Promise } } } +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); +/** FLAKY-TIMING (Andy, flaky-170): wait for a condition (polling), up to a generous deadline, + * instead of a fixed sleep a busy renderer can outrun. A condition that never holds still fails. */ +async function until(what: () => boolean, ms: number): Promise { + const end = Date.now() + ms; + while (!what()) { if (Date.now() > end) return false; await sleep(20); } + return true; +} +const alertUp = () => !!document.querySelector('[role="alert"]'); +/** The error's own reset is 4 s from when it is SET; polling for it to clear needs only a deadline + * well past that. A reset that never fires still fails, at the deadline. */ +const CLEAR_DEADLINE_MS = 15_000; +const cth = (window as unknown as { cth: Record }).cth; +const ERR = 'spawn wt.exe ENOENT: the system terminal could not be started in C:/Users/someone/projects/a-rather-long-folder-name'; +const extra: Record Promise> = { + controlSnapshot: () => Promise.resolve({ paused: false, halted: false, autoDeliveryPaused: false, gatedTools: [], pendingSteers: 0 }), + openTerminalAt: () => Promise.resolve({ ok: false, error: ERR }) +}; +(window as unknown as { cth: unknown }).cth = new Proxy(cth, { + get: (t, name: string) => extra[name] ?? (t as Record)[name] ?? (() => Promise.resolve(undefined)) +}); + +window.__harnessRun = async () => { + const out: Record = {}; + try { + const agent = { id: 'a1', name: 'Alice', status: 'working', action: 'x', progress: 0, ptyId: 'pty-a1', provider: 'codex', cwd: 'C:/x', + character: 'jim', accent: 'mint', description: 'worker', project: 'p', tmuxTarget: '' } as unknown as Agent; + useStore.setState({ agents: [agent], selectedId: 'a1', messageQueues: { a1: [] } } as never); + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: false, capacityEvidence: null, interfered: null }; + createRoot(document.getElementById('root')!).render( +
+ ); + const findTermBtn = () => document.querySelector('[aria-label="Open a system terminal in this agent\'s folder"]')?.closest('button') as HTMLElement | null; + await until(() => !!findTermBtn(), 10_000); + await sleep(300); // let the mount settle (layout) before the "before" reads + const termBtn = findTermBtn(); + out.foundOpenButton = !!termBtn; + // The terminal area starts where the tab bar ends; its top edge moving = the grid moving. + const tabsBottom = () => { + const t = [...document.querySelectorAll('*')].find((e) => e.children.length === 0 && e.textContent?.trim() === 'TERMINAL'); + const bar = t?.closest('div'); + return bar ? Math.round(bar.getBoundingClientRect().bottom * 100) / 100 : -1; + }; + const stripButtons = () => [...document.querySelectorAll('button')] + .filter((b) => /block tools|allow tools|stop after this step|1:1|send/i.test(b.textContent ?? '') && b !== termBtn); + const probe = () => stripButtons().map((b) => { + const r = b.getBoundingClientRect(); + const pts = [[r.left + r.width / 2, r.top + 2], [r.left + r.width / 2, r.top + r.height / 2], [r.left + r.width / 2, r.bottom - 2]]; + return { label: (b.textContent ?? '').trim().slice(0, 24), + hits: pts.map(([x, y]) => { const el = document.elementFromPoint(x, y); return el && (b === el || b.contains(el)) ? 'button' : (el?.getAttribute('role') ?? el?.tagName ?? 'none'); }) }; + }); + out.tabsBefore = tabsBottom(); + out.buttonsBefore = probe(); + termBtn?.click(); + // The error is set when openTerminalAt answers; the 4 s reset starts then, so a poll sees it. + out.alertShown = await until(alertUp, 10_000); + out.alertPointerEvents = (() => { const a = document.querySelector('[role="alert"]'); return a ? getComputedStyle(a).pointerEvents : null; })(); + out.tipCarriesError = (termBtn?.querySelector('[data-tip]')?.getAttribute('data-tip') ?? '').includes(ERR); + out.tabsDuring = tabsBottom(); + out.buttonsDuring = probe(); + out.alertShownAfter = !(await until(() => !alertUp(), CLEAR_DEADLINE_MS)); // it clears on its own (4 s reset) + out.tabsAfter = tabsBottom(); + // D3 (Jim): the THROWN failure path must clear too, not only the ok:false one. + extra.openTerminalAt = () => Promise.reject(new Error('THROWN: ' + ERR)); + termBtn?.click(); + out.thrownAlertShown = await until(alertUp, 10_000); + out.thrownButtons = probe(); + out.thrownAlertShownAfter = !(await until(() => !alertUp(), CLEAR_DEADLINE_MS)); + window.harness.report({ ok: true, ...out }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e), ...out }); + } +}; diff --git a/test/electron-harness/scenarios/dispatch-box-renders.tsx b/test/electron-harness/scenarios/dispatch-box-renders.tsx new file mode 100644 index 0000000000..6338f1a351 --- /dev/null +++ b/test/electron-harness/scenarios/dispatch-box-renders.tsx @@ -0,0 +1,109 @@ +/** + * SCENARIO - COMPOSER-LAG-152 F1 (Jim's report). DOES TYPING IN THE DISPATCH BOX RE-RENDER + * THE WHOLE FLOOR DASHBOARD? + * + * Mounts the PRODUCTION FloorTab with three agents and counts renders of its AGENTS section + * through a getter on each agent's `command`: every AGENTS row reads it once per render + * (inferAgentProvider), and the dispatch box never does. Then: + * A. types N keys into the dispatch textarea -> AGENTS reads (was 3 per key), + * B. one real roster change -> AGENTS reads > 0 (the probe is live), + * C. dispatch -> hiveSend got the typed body with the suggested owner, box cleared, + * D. a task-card seed fills the text and KEEPS the chosen owner, + * E. an issue "Assign" fills the text and resets the owner to "Michael decides". + */ +import { createRoot } from 'react-dom/client'; +import { act, useState } from 'react'; +import { FloorTab } from '../../../src/renderer/src/components/CommandCenterPanel'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} +const sent: unknown[] = []; +const extra: Record unknown> = { + getConfig: () => Promise.resolve({ registeredRepos: ['acme/repo'] }), + githubIssues: () => Promise.resolve({ ok: true, issues: [{ number: 7, title: 'Fix the thing', body: 'details', url: 'https://x/7', labels: [], assignees: [] }] }), + hiveSend: (msg: unknown) => { sent.push(msg); return Promise.resolve({ ok: true }); } +}; +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => extra[name] ?? (name.startsWith('on') ? () => () => {} : name.endsWith('Sync') ? () => null : () => Promise.resolve(undefined)) +}); + +const N = 40; +let agentsReads = 0; +function agent(id: string, name: string, isGod = false): Agent { + const a = { id, name, isGod, status: 'idle', action: '', progress: 0, provider: 'claude', character: 'jim', accent: 'mint', description: 'w', project: 'p', tmuxTarget: '' } as Record; + Object.defineProperty(a, 'command', { enumerable: true, get: () => { agentsReads += 1; return 'claude'; } }); + return a as unknown as Agent; +} + +let setSeed: ((s: { text: string; seq: number }) => void) | null = null; +function Host() { + const [seed, set] = useState({ text: '', seq: 0 }); + setSeed = set; + return ; +} + +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 30)); }); +function setValue(el: HTMLTextAreaElement | HTMLSelectElement, value: string) { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLSelectElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value); + el.dispatchEvent(new Event(el instanceof HTMLTextAreaElement ? 'input' : 'change', { bubbles: true })); +} + +window.__harnessRun = async () => { + try { + useStore.setState({ agents: [agent('god', 'Michael', true), agent('a1', 'Alice'), agent('a2', 'Bob')], selectedId: null } as never); + const root = createRoot(document.getElementById('root')!); + await act(async () => { root.render(); }); + await settle(); + const box = () => document.querySelector('textarea[placeholder^="Describe the task"]') as HTMLTextAreaElement; + const owner = () => document.querySelector('select') as HTMLSelectElement; + const rendersBefore = agentsReads; + + // A. typing + let r0 = agentsReads; + for (let i = 1; i <= N; i++) await act(async () => { setValue(box(), 'x'.repeat(i)); }); + await settle(); + const typingReads = agentsReads - r0; + const typed = box().value; + + // B. a real roster change still renders the AGENTS section + r0 = agentsReads; + await act(async () => { useStore.getState().updateAgent('a1', { name: 'Alicia' }); }); + await settle(); + const realChangeReads = agentsReads - r0; + + // C. dispatch the typed text with a suggested owner + await act(async () => { setValue(owner(), 'a2'); }); + await act(async () => { setValue(box(), 'ship the fix'); }); + const btn = [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'dispatch') as HTMLButtonElement; + await act(async () => { btn.click(); }); + await settle(); + const sentBody = (sent[0] as { body?: string } | undefined)?.body ?? null; + const boxAfterSend = box().value; + + // D. a task-card seed: text only, the chosen owner stays + await act(async () => { setValue(owner(), 'a1'); }); + await act(async () => { setSeed!({ text: 'seeded task', seq: 1 }); }); + await settle(); + const seedText = box().value; const seedOwner = owner().value; + + // E. an issue assign: text AND owner reset + const fetchBtn = [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Fetch issues') as HTMLButtonElement; + await act(async () => { fetchBtn.click(); }); + await settle(); + const assignBtn = [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Assign') as HTMLButtonElement | undefined; + await act(async () => { assignBtn?.click(); }); + await settle(); + const issueText = box().value; const issueOwner = owner().value; + + window.harness.report({ ok: true, N, mountedReads: rendersBefore, typingReads, typed, realChangeReads, sentBody, boxAfterSend, + seedText, seedOwner, foundAssign: !!assignBtn, issueText, issueOwner }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e) }); + } +}; diff --git a/test/electron-harness/scenarios/hidden-fit.tsx b/test/electron-harness/scenarios/hidden-fit.tsx new file mode 100644 index 0000000000..4f6694d4c5 --- /dev/null +++ b/test/electron-harness/scenarios/hidden-fit.tsx @@ -0,0 +1,51 @@ +/** + * SCENARIO - MEMSPIKE-168: a PtyTerminalView mounted inside a display:none wrapper (an inactive + * tab) must not resize its pty to a phantom grid, neither on mount nor on a font-size change. + * Once revealed, the ResizeObserver fits it to the real host and resizes the pty exactly then. + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { PtyTerminalView } from '../../../src/renderer/src/components/PtyTerminalView'; +import { setTerminalFontSize } from '../../../src/renderer/src/components/terminalFontSize'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} + +const resizes: Array<{ id: string; cols: number; rows: number }> = []; +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => { + if (name === 'platform') return 'win32'; + if (name === 'recovering') return false; + if (name === 'resizePty') return (id: string, cols: number, rows: number) => { resizes.push({ id, cols, rows }); return Promise.resolve({ ok: true }); }; + if (name === 'onPtyData' || name === 'onPtyExit' || name.startsWith('on')) return () => () => {}; + if (name.endsWith('Sync')) return () => null; + return () => Promise.resolve({ ok: true }); + } +}); + +const wait = (ms: number) => act(async () => { await new Promise((r) => setTimeout(r, ms)); }); + +window.__harnessRun = async () => { + try { + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:none;width:600px;height:400px'; + document.body.appendChild(wrap); + const root = createRoot(wrap); + await act(async () => { root.render(
); }); + await wait(600); + const afterMount = resizes.length; + await act(async () => { setTerminalFontSize(15); }); + await wait(600); + const afterFontChange = resizes.length; + wrap.style.display = 'block'; + await wait(900); + const shown = resizes.slice(afterFontChange); + window.harness.report({ ok: true, afterMount, afterFontChange, shown }); + } catch (e) { + window.harness.report({ ok: false, error: e instanceof Error ? `${e.message}\n${e.stack}` : String(e) }); + } +}; diff --git a/test/electron-harness/scenarios/history-view.tsx b/test/electron-harness/scenarios/history-view.tsx new file mode 100644 index 0000000000..b29599c577 --- /dev/null +++ b/test/electron-harness/scenarios/history-view.tsx @@ -0,0 +1,104 @@ +/** + * SCENARIO - HISTORY-VIEW-169. Mounts the PRODUCTION HistoryView against an in-memory + * transcript (historyPage stubbed with the real byte-cursor contract) in a hidden window, + * and reports what a real layout does: + * - it opens at the bottom, with only a slice of the rows mounted (virtualised); + * - a new turn is followed while at the bottom; + * - scrolled up, a new turn does NOT move the view, and "Jump to latest" is offered; + * - "Load older" prepends without moving what is on screen; + * - "Jump to latest" returns to the bottom. + * A hidden window renders no frames, so a USER scroll is simulated by setting scrollTop and + * dispatching the scroll event; the component's own scrolls must not depend on that event. + */ +import { createRoot } from 'react-dom/client'; +import { HistoryView } from '../../../src/renderer/src/components/HistoryView'; +import type { HistoryItem, HistoryPage, HistoryRequest } from '../../../src/shared/history'; + +declare global { interface Window { __harnessRun: () => Promise; harness: { report: (p: unknown) => void } } } +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); + +// The "file": one item per 100-byte line. +const LINE = 100; +const file: HistoryItem[] = []; +const add = (n: number, label: string) => { + for (let k = 0; k < n; k += 1) { + const i = file.length; + const kind = i % 3 === 0 ? 'user' : i % 3 === 1 ? 'assistant' : 'tool'; + file.push({ id: `${i * LINE}.0`, kind, at: Date.UTC(2026, 0, 1, 10, 0, i % 60), offset: i * LINE, + text: kind === 'tool' ? `Bash: step ${i}` : `${label} ${i}${i % 7 === 0 ? '\nsecond line\nthird line' : ''}` }); + } +}; +add(1000, 'turn'); +const calls: string[] = []; + +const historyPage = async (req: HistoryRequest): Promise => { + const head = { ok: true as const, provider: 'codex' as const, fileId: 'f1', fileName: 'rollout.jsonl', size: file.length * LINE }; + const limit = req.limit ?? 150; + if (typeof req.after === 'number') { + calls.push('after'); + const from = req.after / LINE; + return { ...head, items: file.slice(from), start: req.after, end: file.length * LINE, atStart: req.after === 0 }; + } + const endIdx = typeof req.before === 'number' ? req.before / LINE : file.length; + calls.push(typeof req.before === 'number' ? 'before' : 'tail'); + const startIdx = Math.max(0, endIdx - limit); + return { ...head, items: file.slice(startIdx, endIdx), start: startIdx * LINE, end: endIdx * LINE, atStart: startIdx === 0 }; +}; +(window as unknown as { cth: unknown }).cth = { historyPage }; + +window.__harnessRun = async () => { + const out: Record = {}; + try { + createRoot(document.getElementById('root')!).render( +
+ ); + await sleep(900); + const sc = document.querySelector('[data-history-view]') as HTMLElement; + const gap = () => Math.round(sc.scrollHeight - sc.scrollTop - sc.clientHeight); + const mounted = () => document.querySelectorAll('[data-hid]').length; + const lastText = () => { const rows = document.querySelectorAll('[data-hid]'); return rows[rows.length - 1]?.textContent ?? ''; }; + const hasJump = () => [...document.querySelectorAll('button')].some((b) => /Jump to latest/.test(b.textContent ?? '')); + out.initialGap = gap(); + out.initialMounted = mounted(); + out.initialLast = lastText(); + + add(3, 'fresh'); + await sleep(2600); // one follow poll + out.followGap = gap(); + out.followLast = lastText(); + + sc.scrollTop = 200; + sc.dispatchEvent(new Event('scroll')); + await sleep(150); + add(2, 'unseen'); + await sleep(2600); + out.heldTop = sc.scrollTop; + out.jumpOffered = hasJump(); + + // Load older: the first row on screen must stay on screen. + sc.scrollTop = 0; + sc.dispatchEvent(new Event('scroll')); + await sleep(150); + const firstVisible = [...document.querySelectorAll('[data-hid]')].find((r) => r.getBoundingClientRect().bottom > sc.getBoundingClientRect().top); + const anchorId = firstVisible?.getAttribute('data-hid'); + const anchorTopBefore = firstVisible ? Math.round(firstVisible.getBoundingClientRect().top) : null; + const older = [...document.querySelectorAll('button')].find((b) => /Load older/.test(b.textContent ?? '')); + out.olderOffered = !!older; + older?.click(); + await sleep(900); + const again = anchorId ? document.querySelector(`[data-hid="${anchorId}"]`) : null; + out.anchorStillMounted = !!again; + out.anchorShift = again && anchorTopBefore !== null ? Math.round(again.getBoundingClientRect().top) - anchorTopBefore : null; + out.mountedAfterOlder = mounted(); + + const jump = [...document.querySelectorAll('button')].find((b) => /Jump to latest/.test(b.textContent ?? '')); + jump?.click(); + await sleep(900); + out.jumpGap = gap(); + out.jumpLast = lastText(); + out.calls = calls; + window.harness.report({ ok: true, ...out }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e), ...out }); + } +}; diff --git a/test/electron-harness/scenarios/impact-loop-common.tsx b/test/electron-harness/scenarios/impact-loop-common.tsx new file mode 100644 index 0000000000..142644075a --- /dev/null +++ b/test/electron-harness/scenarios/impact-loop-common.tsx @@ -0,0 +1,108 @@ +/** + * SCENARIO - IMPACT-LOOP-171, on the REAL App (a recovered view: the live floor with agent cards). + * + * The runaway of 2026-09-27/28: an agent card whose impact is NON-NULL (a capacity hold at 100%, + * or the floor-wide auto-delivery pause) made useAgentImpact re-subscribe and re-read + * control:snapshot on every render, thousands of times a second, until the renderer was killed. + * + * `window.cth` is a fake whose controlSnapshot answers ASYNCHRONOUSLY (a macrotask, as a real IPC + * reply is) and counts every call. The scenario samples, once a second for DURATION_S, the + * control:snapshot calls, the JS heap and the renderer process's private memory (read by main). + * A loop stops the run early (CALL_CAP) so a regression fails fast instead of eating memory. + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { App } from '../../../src/renderer/src/App'; +import { useStore } from '../../../src/renderer/src/store/store'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void; metrics: () => Promise<{ privateMb: number; workingSetMb: number } | null> }; + } +} + +export type ImpactMode = 'hold' | 'pause'; + +const DURATION_S = 20; +const CALL_CAP = 20_000; +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); + +const agents = [ + { id: 'god', name: 'Michael', character: 'michael', accent: 'gold', isGod: true, ptyId: 'pty-god', provider: 'claude', command: 'claude' }, + { id: 'dwight', name: 'Dwight', character: 'dwight', accent: 'mint', ptyId: 'pty-dwight', provider: 'codex', command: 'codex' }, + { id: 'jim', name: 'Jim', character: 'jim', accent: 'sky', ptyId: 'pty-jim', provider: 'claude', command: 'claude' }, + { id: 'andy', name: 'Andy', character: 'andy', accent: 'coral', ptyId: 'pty-andy', provider: 'claude', command: 'claude' } +].map((a) => ({ description: '', project: 'Harness', tmuxTarget: '', cwd: 'C:/harness-test-home', status: 'idle', action: 'awaiting', progress: 0, ...a })); + +const HOLD = { kind: 'CAPACITY_RESERVE_ONLY', verb: 'paused', text: 'paused · Codex reserve only' }; +const FLOOR_PAUSE = { kind: 'DELIVERY_PAUSED', verb: 'paused', text: 'paused · auto-delivery off (floor)' }; + +export function installScenario(mode: ImpactMode): void { + const impactOf = (agentId: string) => (mode === 'pause' ? FLOOR_PAUSE : agentId === 'dwight' ? HOLD : null); + let snapshotCalls = 0; + const asked = new Set(); + let mountedAt = 0; + let reported = false; + const samples: Array> = []; + const report = (payload: Record) => { if (reported) return; reported = true; window.harness.report(payload); }; + const config = { onboardingComplete: true, harnessHome: 'C:/harness-test-home', recentHomes: [] }; + (window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => { + if (name === 'recovering') return true; + if (name === 'platform') return 'win32'; + if (name === 'arch') return 'x64'; + if (name === 'getConfig') return () => Promise.resolve({ ...config }); + if (name === 'takeRecoveryNotice') return () => Promise.resolve(null); + if (name === 'listPtys') return () => Promise.resolve(agents.map((a) => ({ id: a.ptyId, cwd: a.cwd, command: a.command, pid: 1, lastOutputAt: 0, hasOutput: true }))); + if (name === 'controlSnapshot') { + return (agentId: string) => { + snapshotCalls += 1; + asked.add(agentId); + // A loop starves the page's own timers, so the cap reports from inside the call. + if (snapshotCalls === CALL_CAP) { + report({ ok: true, mode, shown: asked.has('dwight'), asked: [...asked], stoppedEarly: true, totalCalls: snapshotCalls, + msToCap: Math.round(performance.now() - mountedAt), samples }); + } + const impact = impactOf(agentId); + const snap = { paused: false, halted: false, autoDeliveryPaused: mode === 'pause', gatedTools: [], pendingSteers: 0, + capacityHold: mode === 'hold' && agentId === 'dwight', capacityEvidence: null, interfered: null, impact }; + return new Promise((res) => { setTimeout(() => res(snap), 0); }); + }; + } + if (name.startsWith('on')) return () => () => {}; + if (name.endsWith('Sync')) return () => null; + if (/^(list|get)/.test(name)) return () => Promise.resolve([]); + return () => Promise.resolve(undefined); + } + }); + + window.__harnessRun = async () => { + const perf = performance as unknown as { memory?: { usedJSHeapSize: number } }; + try { + const host = document.createElement('div'); + host.style.cssText = 'position:absolute;inset:0'; + document.body.appendChild(host); + const root = createRoot(host); + mountedAt = performance.now(); + await act(async () => { root.render(); }); + await act(async () => { await sleep(800); }); + await act(async () => { useStore.setState({ agents, messageQueues: {} } as never); }); + await act(async () => { await sleep(500); }); + // The floor's agent cards each asked main for their impact once (the mount-time read). + const shown = asked.has('dwight'); + let last = snapshotCalls; + let stoppedEarly = false; + for (let s = 1; s <= DURATION_S; s++) { + await sleep(1000); + const m = await window.harness.metrics(); + samples.push({ t: s, calls: snapshotCalls - last, totalCalls: snapshotCalls, heapMb: Math.round((perf.memory?.usedJSHeapSize ?? 0) / 1048576), privateMb: m?.privateMb ?? null }); + last = snapshotCalls; + if (snapshotCalls > CALL_CAP) { stoppedEarly = true; break; } + } + report({ ok: true, mode, shown, asked: [...asked], stoppedEarly, totalCalls: snapshotCalls, samples }); + } catch (e) { + report({ ok: false, error: e instanceof Error ? `${e.message}\n${e.stack}` : String(e), samples, totalCalls: snapshotCalls }); + } + }; +} diff --git a/test/electron-harness/scenarios/impact-loop-hold.tsx b/test/electron-harness/scenarios/impact-loop-hold.tsx new file mode 100644 index 0000000000..d5ad3b844c --- /dev/null +++ b/test/electron-harness/scenarios/impact-loop-hold.tsx @@ -0,0 +1,4 @@ +/** IMPACT-LOOP-171: a 100% capacity HOLD on one agent (Dwight). See impact-loop-common.tsx. */ +import { installScenario } from './impact-loop-common'; + +installScenario('hold'); diff --git a/test/electron-harness/scenarios/impact-loop-pause.tsx b/test/electron-harness/scenarios/impact-loop-pause.tsx new file mode 100644 index 0000000000..30489973e9 --- /dev/null +++ b/test/electron-harness/scenarios/impact-loop-pause.tsx @@ -0,0 +1,4 @@ +/** IMPACT-LOOP-171: the floor-wide auto-delivery PAUSE (every card non-null). See impact-loop-common.tsx. */ +import { installScenario } from './impact-loop-common'; + +installScenario('pause'); diff --git a/test/electron-harness/scenarios/input-origin.ts b/test/electron-harness/scenarios/input-origin.ts new file mode 100644 index 0000000000..b16ca9888f --- /dev/null +++ b/test/electron-harness/scenarios/input-origin.ts @@ -0,0 +1,301 @@ +/** + * SCENARIO - L0-FUSION stage 4. The half of input provenance that only a REAL xterm + * can show: the two closing regimes, the drain, the excluded events, the programmatic + * paste, the self-test, and the mouse-mode mirror. + * + * The instrument is the production `terminalPool` with a bridge stub whose `writePty` + * RECORDS the origin each byte was sent with. That is the exact fact main would receive, + * read at the exact place it leaves the renderer, so a wrong classification here is a + * wrong classification in production and not a re-implementation of one. + * + * Each arm names the mutant it kills. A test that only demonstrates the right behaviour + * on the cases it was written from is the shape of every weak test this floor has found. + */ +import { + acquireTerminal, attachTerminal, disposeTerminal, resetTerminal +} from '../../../src/renderer/src/components/terminalPool'; +import { + inspectInputOrigin, markHumanOrigin, makeNonceCorrelatedProbe, selftestQuery, selftestReply +} from '../../../src/renderer/src/components/inputOrigin'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +interface Sent { id: string; data: string; origin: string } +const sent: Sent[] = []; +const reported: unknown[] = []; + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: unsub(), onPtyExit: unsub(), onPtyRelaunch: unsub(), + writePty: (id: string, data: string, origin: string) => { sent.push({ id, data, origin }); return Promise.resolve({ ok: true }); }, + redrawPty: () => { /* no pty */ }, resizePty: () => { /* no pty */ }, + copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve(), + // The mirror, recorded rather than dropped, so the mode arm can read what main would. + reportTerminalInputState: (_id: string, state: unknown) => { reported.push(state); return Promise.resolve({ ok: true }); } + }; +} + +const tick = () => new Promise((r) => { setTimeout(r, 0); }); +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); +const lastOrigin = () => sent.length ? sent[sent.length - 1].origin : null; +const key = (ta: HTMLTextAreaElement, k: string, code: number) => + ta.dispatchEvent(new KeyboardEvent('keydown', { key: k, code: k, keyCode: code, which: code, bubbles: true, cancelable: true } as KeyboardEventInit)); + +window.__harnessRun = async () => { + const result: Record = {}; + try { + installBridgeStub(); + const root = document.getElementById('root')!; + const entry = acquireTerminal('io'); + attachTerminal(entry, root); + const term = entry.term; + const ta = term.textarea!; + result.opened = entry.opened; + // Let the startup self-test run to completion before measuring anything; it swallows + // its own bytes, so `sent` must still be empty afterwards. + for (let i = 0; i < 20 && entry.inputSelfTest === 'unknown'; i++) await tick(); + result.selfTest = entry.inputSelfTest; + result.selfTestLeakedBytes = sent.length; + sent.length = 0; + + // ARM 1 - keyboard, same tick. Kills: "no capture listener / listener after xterm's". + key(ta, 'ArrowRight', 39); + result.keyboard = { origin: lastOrigin(), data: sent[sent.length - 1]?.data }; + await tick(); + result.keyboardWindowClosed = inspectInputOrigin('io')?.sameTick === false; + + // ARM 2 - programmatic input with no DOM event. Kills: "tag everything xterm emits". + // `term.input()` and `term.paste()` both flag wasUserInput=true INSIDE xterm; the + // design says that flag is not ours to trust, and the classifier must say CONTROL. + sent.length = 0; term.input('prog'); result.programmaticInput = lastOrigin(); + sent.length = 0; term.paste('pasted'); result.programmaticPaste = lastOrigin(); + + // ARM 3 - the SAME action, marked at code we own, is HUMAN. Kills: "mark is a no-op". + sent.length = 0; markHumanOrigin('io', 'paste-async'); term.paste('user'); + result.markedPaste = lastOrigin(); + + // ARM 4 - a terminal protocol reply is CONTROL even right after a keystroke. Kills: + // "the keyboard window leaks into write() processing". + key(ta, 'ArrowRight', 39); await tick(); sent.length = 0; + await write(term, '\x1b[6n'); + result.dsrReply = { origin: lastOrigin(), data: sent[sent.length - 1]?.data }; + + // ARM 5 - focus is EXCLUDED. A focus report emitted inside a focus dispatch must + // read CONTROL. Kills: "listen to every event inside term.element". + sent.length = 0; + ta.addEventListener('focus', () => { term.input('\x1b[I', false); }, { once: true }); + ta.dispatchEvent(new FocusEvent('focus')); + result.focusReport = lastOrigin(); + + // ARM 5b - BLOCKER 1: a protocol reply INSIDE a held window is CONTROL and does NOT + // extend the drain. Open held with a composition event, then write a DSR; xterm's CPR + // reply lands while held is open. Before the fix classifyOutbound returned HUMAN for + // every held byte and rearmHold reset the timer, so a cursor-polling TUI held the + // window open forever. Now: ESC-prefixed reply -> CONTROL, no rearm. + sent.length = 0; + ta.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + result.heldOpenBeforeReply = inspectInputOrigin('io')?.held === true; + await write(term, ''); + result.replyInHeld = lastOrigin(); // must be CONTROL, not HUMAN + // A printable composition byte right after must still be HUMAN: the reply neither + // stole the window nor, by not rearming, is the only thing keeping it open. + term.input('x', false); + result.humanAfterReplyInHeld = lastOrigin(); + + // ARM 6 - a REAL IME through xterm's CompositionHelper (Dwight 23.2: the old arm + // used manual term.input()). compositionstart opens our held window; xterm emits the + // composed text on a setTimeout(0); the emitted byte must classify HUMAN. This drives + // the actual DOM pipeline - compositionstart/update/end - not an injected byte. + ta.focus(); + ta.value = ''; + ta.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + ta.value = 'あ'; + ta.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'あ', bubbles: true })); + await tick(); // xterm schedules compositionPosition.end (setTimeout 0) + sent.length = 0; + ta.dispatchEvent(new CompositionEvent('compositionend', { data: 'あ', bubbles: true })); + for (let i = 0; i < 20 && sent.length === 0; i++) await sleep(5); // _finalizeComposition emits on setTimeout(0) + result.realImeData = sent.map((x) => x.data).join(''); + result.realImeOrigin = lastOrigin(); + const imeEmittedAt = Date.now(); + + // ARM 7 - BOTH sides of the 50 ms boundary, with LITERALS, not the imported constant + // (Dwight 23.2: importing HELD_DRAIN_MS made the test's own wait move with the boundary, + // so it proved eventual expiry, not that 50 is what holds). The held window was just + // rearmed by the IME emission. The bracket [25, 145] ms is deliberately loose against + // timer jitter while pinning both directions independent of the constant: raise it past + // 145 and the upper read fails; drop it below 25 and the lower read does. inspect() is a + // pure read and does not itself rearm the drain. + // FLAKY-TIMING: the reads used to be sleeps measured from the IME emit, so a starved renderer + // woke past 50 ms and read "drained" at "~25 ms". Now the window is (re)opened synchronously and + // the two reads are timers scheduled in the SAME TICK as its drain timer: expired timers run in + // due-time order however late the renderer wakes, so +25 always reads before the +50 drain and + // +145 always after it. The literals still pin both directions of the constant. + void imeEmittedAt; + await sleep(60); // let the IME's held window drain first + ta.dispatchEvent(new Event('input', { bubbles: true })); // opens held NOW: drain due at +HELD_DRAIN_MS + const readAt = (ms: number) => new Promise((r) => { setTimeout(() => r(inspectInputOrigin('io')?.held), ms); }); + const at25 = readAt(25), at145 = readAt(145); + result.heldAt25 = await at25; // < 50 -> still held + result.heldAt145 = await at145; // > 50 -> drained + + // ARM 8 - the mouse-mode MIRROR follows the TUI and comes back. Kills: "one-shot at + // arm time". Read from xterm's own modes, forwarded to (the stub of) main. + reported.length = 0; + await write(term, '\x1b[?1000h'); await tick(); + const onReport = reported[reported.length - 1] as { mouseTrackingMode?: string } | undefined; + await write(term, '\x1b[?1000l'); await tick(); + const offReport = reported[reported.length - 1] as { mouseTrackingMode?: string } | undefined; + result.mirror = { + afterOn: onReport?.mouseTrackingMode, afterOff: offReport?.mouseTrackingMode, + xtermNow: term.modes.mouseTrackingMode, reports: reported.length + }; + + // ARM 9 - BLOCKER 4 (Dwight 23.3): a same-id respawn re-establishes provenance. + // resetTerminal mirrors the relaunch path. After it the entry must reset to 'unknown' + // and re-run the self-test back to 'pass', and send a FRESH report - not sit on the + // old cached state while main's new session is NO_STATE. + const reportsBefore = reported.length; + resetTerminal('io'); + result.selfTestResetImmediate = entry.inputSelfTest; // synchronously back to 'unknown' + for (let i = 0; i < 40 && entry.inputSelfTest === 'unknown'; i++) await tick(); + result.selfTestAfterReset = entry.inputSelfTest; // proven again + const afterReset = reported.slice(reportsBefore); + result.reportsAfterReset = afterReset.length; + result.lastReportSelfTest = (afterReset[afterReset.length - 1] as { selfTest?: string } | undefined)?.selfTest; + + // ARM 10 - BLOCKER 4 retry half: a report main REJECTS is retried on backoff and the + // cache is set only on ACK. Reject the next report once, then change state; the retry + // (100ms) must land the state that the first, rejected attempt carried. + let failsLeft = 1; + const realReport = window.cth.reportTerminalInputState as (id: string, st: unknown) => Promise<{ ok: boolean }>; + const recordedOk: unknown[] = []; + window.cth.reportTerminalInputState = (id: string, st: unknown) => { + if (failsLeft > 0) { failsLeft--; return Promise.resolve({ ok: false, error: 'no pty (simulated)' }); } + recordedOk.push(st); return realReport(id, st); + }; + await write(term, '[?1000h'); // mouse mode -> new state -> first report REJECTED + // The 100 ms retry fires and is accepted. FLAKY-TIMING: poll for it (5 s deadline) instead of a + // fixed 400 ms; a retry that never comes still fails, at the deadline. + for (let i = 0; i < 250 && !recordedOk.some((st) => (st as { mouseTrackingMode?: string }).mouseTrackingMode === 'vt200'); i++) await sleep(20); + window.cth.reportTerminalInputState = realReport; + result.retryLanded = recordedOk.some((st) => (st as { mouseTrackingMode?: string }).mouseTrackingMode === 'vt200'); + await write(term, '[?1000l'); + + // ── The five mutant-killing arms this replacement adds (Dwight 24.1 / 24.3) ────── + // Each NAMES the defective variant it kills and is written so it FAILS against that + // variant; a green arm here is only meaningful because its mutant is red. + + // ARM 11 - GAP A (Dwight 24.1): held AND same-tick open at once. A real human arrow + // dispatched while a held (IME) window is open must classify HUMAN. classifyOutbound + // checks sameTick BEFORE held, so the ESC-prefixed arrow is the human's key, not a + // reply. KILLS THE BRANCH-SWAP MUTANT: swap the two branches and held is consulted + // first, where isTerminalReply sees the leading ESC and returns CONTROL - this fails. + await sleep(60); // let any prior held drain first + ta.dispatchEvent(new Event('input', { bubbles: true })); // opens the held window (no xterm composition) + result.gapA_heldOpen = inspectInputOrigin('io')?.held === true; + sent.length = 0; + key(ta, 'ArrowRight', 39); // opens same-tick; xterm emits ESC[C + result.gapA_arrowData = sent[sent.length - 1]?.data; + result.gapA_arrowOrigin = lastOrigin(); // HUMAN (only if sameTick is checked first) + result.gapA_heldStillOpen = inspectInputOrigin('io')?.held === true; // both were open at emit + + // ARM 12 - GAP B (Dwight 24.1): a protocol reply inside held returns CONTROL and MUST + // NOT rearm the drain. Open held at T; at ~T+30 inject a CPR reply; then poll PAST the + // ORIGINAL 50ms drain but before a reply-rearm drain (~T+80) would elapse. The window + // must already be closed. KILLS THE REARM MUTANT: a variant that rearms after returning + // CONTROL keeps the window open at the poll, so gapB_heldAfterOriginalDrain reads true. + await sleep(60); // ensure the Gap-A held window has drained + // FLAKY-TIMING: the reply and the poll are timers scheduled in the SAME TICK that opens held at T, + // so they run in due-time order against its +50 drain whatever the load: the reply at +30 is + // always inside the window, and the read at +66 is always after the original drain and before a + // reply-rearmed one (+80). + ta.dispatchEvent(new Event('input', { bubbles: true })); // open held at T + const replied = new Promise((r) => { setTimeout(() => { + sent.length = 0; + term.input('\x1b[6;5R', false); // a CPR reply INSIDE held, at T+30 + result.gapB_replyOrigin = lastOrigin(); // CONTROL + result.gapB_heldRightAfterReply = inspectInputOrigin('io')?.held === true; // still true: original drain not yet + r(); + }, 30); }); + const polled = new Promise((r) => { setTimeout(() => { + result.gapB_heldAfterOriginalDrain = inspectInputOrigin('io')?.held; // MUST be false (no rearm) + r(); + }, 66); }); + await replied; await polled; + + // ARM 13 - BLOCKER 2 overlap (Dwight 24.3), CONVERGENCE SANITY (not a standalone + // mutant-killer - the reused-id arm below is): fire two resets back-to-back so the first + // incarnation is superseded mid-flight, and confirm the entry still converges to the + // LATEST incarnation's pass rather than wedging. It cannot by itself catch a late stale + // publish, which lands ~SELFTEST_TIMEOUT_MS later; ARM 14 is the deterministic proof that + // a superseded/disposed run cannot report. + resetTerminal('io'); + const genAfterFirstReset = entry.generation; + resetTerminal('io'); // supersede the first run immediately + result.overlap_genBumped = entry.generation > genAfterFirstReset; + result.overlap_immediate = entry.inputSelfTest; // 'unknown': the latest run just started + for (let i = 0; i < 80 && entry.inputSelfTest === 'unknown'; i++) await tick(); + result.overlap_converged = entry.inputSelfTest; // 'pass' from the latest run + + // ARM 14 - BLOCKER 2 reused-id fail-open (Dwight 24.3): a disposed terminal's OUTSTANDING + // report retry must never fire - or a reused ptyId inherits its stale 'eligible' evidence. + // Acquire a second id, force a report REJECT so a retry is scheduled, dispose before the + // 100ms backoff, and assert the retry never calls the bridge again for that id. KILLS THE + // MUTANT where disposeTerminal leaves the entry un-exited and the generation unbumped. + const entry2 = acquireTerminal('io2'); + attachTerminal(entry2, root); + for (let i = 0; i < 60 && entry2.inputSelfTest === 'unknown'; i++) await tick(); + const baseReport = window.cth.reportTerminalInputState as (id: string, st: unknown) => Promise<{ ok: boolean }>; + const io2Calls: string[] = []; + window.cth.reportTerminalInputState = (id: string, _st: unknown) => { + io2Calls.push(id); + return Promise.resolve({ ok: false, error: 'reject-always (simulated)' }); // force a retry to be scheduled + }; + await write(entry2.term, '\x1b[?1000h'); // new state -> report -> rejected -> retry scheduled + await tick(); + result.disposed_hadPendingReport = io2Calls.length > 0; // sanity: a report really went out first + disposeTerminal('io2'); // ends the incarnation: exited + generation bump + const io2CallsAtDispose = io2Calls.length; + await sleep(350); // the 100ms + 250ms retries would fire under the mutant + result.disposed_retryCallsAfterDispose = io2Calls.length - io2CallsAtDispose; // MUST be 0 + window.cth.reportTerminalInputState = baseReport; // restore the recording stub + + // ARM 15 - BLOCKER 1 real-xterm adversarial (god fix-round-3; human requirement): a FOREIGN + // reply must FAIL token correlation against REAL xterm, not merely in a byte-by-byte pure + // test. Install the PRODUCTION nonce probe with a known nonce, then drive REAL xterm to emit + // foreign replies - a genuine CPR (from a foreign DSR) and a DECRQM reply for a DIFFERENT + // nonce - and then OUR own nonce query. Only OUR reply carries the token, so only it is + // consumed; the foreign replies fail the exact-match and reach the pty. No same-parse + // adjacency is used. KILLS THE SHAPE-ONLY MUTANT: a probe matching any CPR/$y would swallow + // the foreign CPR or the foreign $y, so they would NOT reach the pty. + const advNonce = 424242; + const advExpected = selftestReply(advNonce); + let advProbeOrigin: string | undefined; + entry.inputOriginProbe = makeNonceCorrelatedProbe(advExpected, (o) => { advProbeOrigin = o; }); + sent.length = 0; + await write(term, ''); // foreign DSR -> REAL CPR -> fails token -> flows + await write(term, selftestQuery(222222)); // foreign DECRQM (different nonce) -> ESC[222222;0$y -> fails token -> flows + await write(term, selftestQuery(advNonce)); // OUR query -> xterm echoes ESC[424242;0$y -> matches -> consumed + result.adv_foreignCprReachedPty = sent.some((x) => /^\[\d+;\d+R$/.test(x.data) && x.origin === 'CONTROL'); + result.adv_foreignYReachedPty = sent.some((x) => x.data === '[222222;0$y' && x.origin === 'CONTROL'); + result.adv_ourReplyConsumed = advProbeOrigin === 'CONTROL' && !sent.some((x) => x.data === advExpected); + entry.inputOriginProbe = undefined; + + disposeTerminal('io'); + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/models-refresh-button.tsx b/test/electron-harness/scenarios/models-refresh-button.tsx new file mode 100644 index 0000000000..3f0d0f006e --- /dev/null +++ b/test/electron-harness/scenarios/models-refresh-button.tsx @@ -0,0 +1,82 @@ +/** + * SCENARIO - REFRESH-MODELS (1.1.73): the Settings button flow, on the PRODUCTION panel and store. + * + * Mounts ModelsRefreshPanel next to a probe that renders modelsForProvider('antigravity') the way a + * picker does. The bridge is a stub: + * - modelCatalog() answers null (no file yet); + * - refreshModels() answers after a delay with rows and a new file, and pushes the file through + * onModelCatalogChanged, as main does. + * Checks: + * A. before: the picker shows the FLOOR (3.8 Flash, no retired 3.5), "Not refreshed yet", no lookup + * at mount (refreshModels never called by mounting); + * B. click: the button says "Refreshing…" and is disabled; a second click is ignored (one call); + * C. after: one row per provider (listed / no list / not installed / failed + kept list), the + * picker shows the refreshed list (a new model appears), and "Last refreshed" is shown. + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { ModelsRefreshPanel } from '../../../src/renderer/src/components/ModelsRefreshPanel'; +import { loadModelCatalog, modelsForProvider, useModelCatalogVersion } from '../../../src/renderer/src/store/config'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} + +const calls = { modelCatalog: 0, refreshModels: 0 }; +let push: ((f: unknown) => void) | null = null; +const FILE = { + version: 1, refreshedAt: Date.parse('2026-09-28T09:00:00Z'), + providers: { + antigravity: { status: 'ok', source: 'agy models', fetchedAt: 1, models: [ + { id: 'Gemini 3.9 Flash (High)', label: 'Gemini 3.9 Flash · High' }, + { id: 'Gemini 3.8 Flash (High)', label: 'Gemini 3.8 Flash · High' }] }, + claude: { status: 'unsupported', reason: 'no list command (no Anthropic API key stored; built-in list kept)' }, + opencode: { status: 'not-installed', reason: 'opencode not found' }, + codex: { status: 'failed', reason: 'timeout', models: [{ id: 'gpt-6-astra', label: 'GPT-6 Astra' }] } + } +}; +const ROWS = [ + { provider: 'antigravity', status: 'ok', count: 2, added: ['Gemini 3.9 Flash (High)'], removed: ['Gemini 3.5 Flash (Medium)'], ms: 1800 }, + { provider: 'claude', status: 'unsupported', count: 0, added: [], removed: [], reason: 'no list command (no Anthropic API key stored; built-in list kept)', ms: 1 }, + { provider: 'opencode', status: 'not-installed', count: 0, added: [], removed: [], reason: 'opencode not found', ms: 40 }, + { provider: 'codex', status: 'failed', count: 1, added: [], removed: [], reason: 'timeout', keptLast: true, ms: 20000 } +]; +(window as unknown as { cth: unknown }).cth = { + modelCatalog: () => { calls.modelCatalog += 1; return Promise.resolve(null); }, + onModelCatalogChanged: (cb: (f: unknown) => void) => { push = cb; return () => { push = null; }; }, + refreshModels: () => { + calls.refreshModels += 1; + return new Promise((resolve) => setTimeout(() => { push?.(FILE); resolve({ file: FILE, rows: ROWS }); }, 150)); + } +}; + +function PickerProbe() { + useModelCatalogVersion(); + return
    {modelsForProvider('antigravity').map((m) =>
  • {m.label}
  • )}
; +} + +const settle = (ms = 30) => act(async () => { await new Promise((r) => setTimeout(r, ms)); }); +const text = (sel: string) => (document.querySelector(sel)?.textContent ?? ''); + +window.__harnessRun = async () => { + try { + await loadModelCatalog(); + const root = createRoot(document.getElementById('root')!); + await act(async () => { root.render(<>); }); + await settle(); + const before = { picker: [...document.querySelectorAll('#agy-picker li')].map((l) => l.textContent), panel: document.body.textContent ?? '', refreshCalls: calls.refreshModels }; + const button = [...document.querySelectorAll('button')].find((b) => /Refresh models/.test(b.textContent ?? '')) as HTMLButtonElement; + await act(async () => { button.click(); }); + const during = { label: button.textContent, disabled: button.disabled }; + await act(async () => { button.click(); }); // ignored while busy + await settle(300); + const rows = [...document.querySelectorAll('[data-testid="models-refresh-rows"] tr')].map((tr) => [...tr.querySelectorAll('td')].map((td) => td.textContent)); + const after = { picker: [...document.querySelectorAll('#agy-picker li')].map((l) => l.textContent), panel: document.body.textContent ?? '', rows, button: button.textContent, disabled: button.disabled }; + window.harness.report({ ok: true, calls, before, during, after, lastRefreshed: /Last refreshed/.test(text('body')) }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e) }); + } +}; diff --git a/test/electron-harness/scenarios/note-draft-commits.tsx b/test/electron-harness/scenarios/note-draft-commits.tsx new file mode 100644 index 0000000000..98723986d9 --- /dev/null +++ b/test/electron-harness/scenarios/note-draft-commits.tsx @@ -0,0 +1,129 @@ +/** + * SCENARIO - COMPOSER-LAG-152 F2 (Jim's report). HOW OFTEN DOES TYPING A PRIVATE NOTE WRITE + * THE ROSTER? + * + * Mounts the PRODUCTION PrivateNoteTextarea wired to the PRODUCTION store's setAgentNote, + * exactly as the focus-mode roster does, and counts `agents` changes and roster persistence + * writes (localStorage 'cth.agents'): + * A. N keys typed fast -> writes during typing (was N), then one after the pause, + * B. type, then blur -> committed at once (no waiting for the debounce), + * C. type, then close (unmount) -> committed at once, + * D. type, then beforeunload -> committed, AND the store's own last roster flush (which + * also runs on beforeunload) already carries it, + * E. reopen -> the editor shows the saved note. + */ +import { createRoot } from 'react-dom/client'; +import { act, useState } from 'react'; +import { PrivateNoteTextarea } from '../../../src/renderer/src/components/PrivateNoteTextarea'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} +const rosterWrites: Array<{ agents: Array<{ id: string; note?: string }> }> = []; +const extra: Record unknown> = { + rosterWrite: (snap: unknown) => { rosterWrites.push(snap as never); return Promise.resolve(undefined); } +}; +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => extra[name] ?? (name.startsWith('on') ? () => () => {} : name.endsWith('Sync') ? () => null : () => Promise.resolve(undefined)) +}); + +let persists = 0; +const realSetItem = Storage.prototype.setItem; +Storage.prototype.setItem = function (key: string, value: string) { + if (key === 'cth.agents') persists += 1; + return realSetItem.call(this, key, value); +}; + +const N = 40; +let agentsChanges = 0; +let setOpen: ((open: boolean) => void) | null = null; +function Host() { + const [open, set] = useState(true); + setOpen = set; + const note = useStore((s) => s.agents.find((a) => a.id === 'a1')?.note ?? ''); + return open + ? useStore.getState().setAgentNote('a1', n)} onEscape={() => set(false)} ariaLabel="Note for Alice" style={{}} /> + : null; +} + +const sleep = (ms: number) => act(async () => { await new Promise((r) => setTimeout(r, ms)); }); +const area = () => document.querySelector('textarea[aria-label="Note for Alice"]') as HTMLTextAreaElement | null; +function setValue(el: HTMLTextAreaElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); +} +const noteNow = () => useStore.getState().agents.find((a) => a.id === 'a1')?.note ?? ''; +async function typeRun(prefix: string) { + for (let i = 1; i <= N; i++) await act(async () => { setValue(area()!, prefix + 'k'.repeat(i)); }); + return prefix + 'k'.repeat(N); +} +const counts = () => ({ persists, agentsChanges }); +const delta = (a: { persists: number; agentsChanges: number }) => ({ persists: persists - a.persists, agentsChanges: agentsChanges - a.agentsChanges }); + +window.__harnessRun = async () => { + try { + useStore.setState({ agents: [{ id: 'a1', name: 'Alice', status: 'idle', note: 'old' } as unknown as Agent], selectedId: 'a1' } as never); + useStore.subscribe((s, p) => { if (s.agents !== p.agents) agentsChanges += 1; }); + const root = createRoot(document.getElementById('root')!); + await act(async () => { root.render(); }); + const initialValue = area()?.value; + + // A. fast typing, then the pause + let c0 = counts(); + const a = await typeRun('A'); + const duringTyping = delta(c0); + const noteDuringTyping = noteNow(); + await sleep(700); + const afterPause = delta(c0); + const noteAfterPause = noteNow(); + + // B. blur commits at once + c0 = counts(); + const b = await typeRun('B'); + // The harness window is never focused, so blur() fires no event; send React's focusout. + await act(async () => { area()!.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + const afterBlur = delta(c0); + const noteAfterBlur = noteNow(); + await sleep(700); + const afterBlurPause = delta(c0); + + // C. closing commits at once + await act(async () => { area()!.focus(); }); + c0 = counts(); + const cText = await typeRun('C'); + await act(async () => { setOpen!(false); }); + const afterClose = delta(c0); + const noteAfterClose = noteNow(); + + // E (first half). reopen shows the saved note + await act(async () => { setOpen!(true); }); + const reopenedValue = area()?.value; + + // D. quit: beforeunload + const d = await typeRun('D'); + rosterWrites.length = 0; + window.dispatchEvent(new Event('beforeunload')); + const noteAfterUnload = noteNow(); + const lastFlushNote = rosterWrites.length ? rosterWrites[rosterWrites.length - 1].agents.find((x) => x.id === 'a1')?.note ?? null : null; + + // F. C3 (Jim's pin): SLOW typing, a key every 100 ms for over 2 s. The debounce must + // restart on EVERY key: nothing is committed while typing, one commit after the pause. + c0 = counts(); + let slow = ''; + for (let i = 0; i < 22; i++) { slow += 's'; const v = slow; await act(async () => { setValue(area()!, v); }); await sleep(100); } + const slowDuring = delta(c0); + await sleep(700); + const slowAfter = delta(c0); + const noteAfterSlow = noteNow(); + + window.harness.report({ ok: true, N, slow, slowDuring, slowAfter, noteAfterSlow, initialValue, a, duringTyping, noteDuringTyping, afterPause, noteAfterPause, + b, afterBlur, noteAfterBlur, afterBlurPause, cText, afterClose, noteAfterClose, reopenedValue, + d, noteAfterUnload, rosterFlushesOnUnload: rosterWrites.length, lastFlushNote }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e) }); + } +}; diff --git a/test/electron-harness/scenarios/render-capabilities.ts b/test/electron-harness/scenarios/render-capabilities.ts new file mode 100644 index 0000000000..201e632897 --- /dev/null +++ b/test/electron-harness/scenarios/render-capabilities.ts @@ -0,0 +1,131 @@ +/** + * SCENARIO — does this harness support the READS that C2.11 #14 requires? + * + * WHY THIS IS NOT #14 EVIDENCE, AND MUST NEVER BE CITED AS ANY. #14 is about a + * weekly-blocked capacity strip: exactly one subordinate five-hour token, no meter + * or progress semantics, no positive-capacity colour, an inseparable named blocker. + * THAT COMPONENT DOES NOT EXIST - there is no capacity strip in the renderer, and + * building one is L0-UI, which is held. So #14 cannot be observed by any harness + * today, and the reason is the absent subject rather than a missing instrument. + * + * What this scenario does instead is prove the INSTRUMENT on a component that really + * exists, so that when the strip is built the evidence is one scenario away rather + * than an open question. Each read below is one of #14's, taken against the real + * terminal surface: + * - DOM presence and cardinality + * - COMPUTED style, which needs a layout engine and is the reason jsdom was excluded + * - the ACCESSIBILITY TREE as the platform computes it, not the markup + * - a duplicate rendered-token scan over visible text + * - real RESPONSIVE behaviour, by resizing the real window + */ +import { acquireTerminal, attachTerminal, disposeTerminal } from '../../../src/renderer/src/components/terminalPool'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { + report: (payload: unknown) => void; + resize: (w: number, h: number) => Promise<{ size: [number, number] }>; + axTree: () => Promise | { error: string }>; + }; + cth: Record; + } +} + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: unsub(), onPtyExit: unsub(), onPtyRelaunch: unsub(), + writePty: () => { /* no pty */ }, redrawPty: () => { /* no pty */ }, + resizePty: () => { /* no pty */ }, copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve() + }; +} + +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); + +/** Every visible token on screen, for the duplicate scan #14 asks for. */ +function visibleTokens(root: HTMLElement): string[] { + return (root.innerText || '').split(/\s+/u).filter(Boolean); +} + +window.__harnessRun = async () => { + const result: Record = {}; + try { + installBridgeStub(); + const root = document.getElementById('root')!; + const entry = acquireTerminal('caps'); + attachTerminal(entry, root); + await write(entry.term, 'REMAINING 42%\r\nblocked by Weekly\r\n'); + + // ── DOM: presence and cardinality ───────────────────────────────────────── + const screens = root.querySelectorAll('.xterm-screen'); + result.dom = { + xtermMounted: root.querySelectorAll('.xterm').length, + screens: screens.length, + rows: root.querySelectorAll('.xterm-rows > div').length + }; + + // ── COMPUTED STYLE: a real layout engine, real px, real resolved colour ──── + // The FONT lives on `.xterm`, not on `.xterm-screen` - reading the wrong + // element returns the document default and looks like a real answer. + const screen = screens[0] as HTMLElement; + const styled = root.querySelector('.xterm') as HTMLElement; + const cs = getComputedStyle(styled); + const box = screen.getBoundingClientRect(); + result.computedStyle = { + fontFamily: cs.fontFamily, + // Resolved to px by the engine rather than echoed back as authored. + fontSizePx: cs.fontSize, + colorIsResolvedRgb: /^rgb/u.test(getComputedStyle(root).color), + laidOut: box.width > 0 && box.height > 0, + width: Math.round(box.width) + }; + + // ── ACCESSIBILITY TREE, as the platform computes it ─────────────────────── + const ax = await window.harness.axTree(); + result.accessibility = Array.isArray(ax) + ? { available: true, nodes: ax.length, roles: [...new Set(ax.map((n) => n.role).filter(Boolean))].slice(0, 12) } + : { available: false, ...ax }; + + // ── DUPLICATE RENDERED TOKEN SCAN, AND A TRAP WORTH RECORDING ───────────── + // A scan over `innerText` returns NOTHING here, and not because there is + // nothing on screen: xterm's WebGL renderer paints into a , so the + // rendered text is not in the DOM at all. A #14 duplicate-token scan written + // against `innerText` would therefore report "no duplicates" for a canvas + // surface however many times a token was actually drawn - a clean pass for the + // wrong reason, which is the worst kind. Both sources are reported so the + // difference is visible rather than inferred. + const domTokens = visibleTokens(root); + const axNames = Array.isArray(ax) + ? ax.flatMap((n) => (n.name ? n.name.split(/\s+/u) : [])).filter(Boolean) + : []; + const tally = (list: string[]) => { + const counts = new Map(); + for (const t of list) counts.set(t, (counts.get(t) ?? 0) + 1); + return counts; + }; + const axCounts = tally(axNames); + result.duplicateScan = { + domTokens: domTokens.length, + accessibleTokens: axNames.length, + // The reading a #14 scan would actually use for a canvas surface. + weeklyOccurrencesAccessible: axCounts.get('Weekly') ?? 0, + domTextIsEmptyBecauseCanvas: domTokens.length === 0 && screens.length > 0 + }; + + // ── RESPONSIVE: resize the REAL window and re-measure ───────────────────── + const before = entry.term.cols; + await window.harness.resize(520, 700); + entry.fit.fit(); + const after = entry.term.cols; + result.responsive = { colsAtFullWidth: before, colsAtMinWidth: after, reflowed: after < before }; + + disposeTerminal('caps'); + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/terminal-chrome-grid.tsx b/test/electron-harness/scenarios/terminal-chrome-grid.tsx new file mode 100644 index 0000000000..313567ca18 --- /dev/null +++ b/test/electron-harness/scenarios/terminal-chrome-grid.tsx @@ -0,0 +1,113 @@ +/** + * SCENARIO - CODEX-REDRAW-151. THE TERMINAL'S BOX, RENDERED, WHILE THE CHROME AROUND IT + * CHANGES STATE. + * + * Codex (0.154) answers ANY change of rows or cols by replaying its whole transcript + * (measured: 191 KB for ~200 rows of history, linear). The terminal fills whatever its flex + * column leaves over, so any sibling that changes height with STATE is a replay trigger. + * Measured on the shipped 1.1.50: every AgentControlStrip click flashed a confirmation + * line that wrapped onto 2-3 lines in the sidebar for 1.8 s, 17 -> 14 -> 17 rows, two + * replays per click. + * + * This mounts the PRODUCTION AgentControlStrip and MessageQueueComposer around a stand-in + * for the terminal, in a column as wide as the default sidebar (420 px) and laid out as + * AgentDetailPanel lays it out, drives each state through the real components (trusted + * clicks through Chromium, bridge answers as main would), and reports the stand-in's + * rendered height after every step. The terminal's grid is a pure function of that height, + * so an unchanged height IS an unchanged grid. + */ +import { bridge } from './composer-bridge-stub'; +import { createRoot } from 'react-dom/client'; +import { AgentControlStrip } from '../../../src/renderer/src/components/AgentControlStrip'; +import { MessageQueueComposer } from '../../../src/renderer/src/components/MessageQueueComposer'; +import { useStore, type Agent } from '../../../src/renderer/src/store/store'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void; click: (x: number, y: number) => Promise; axTree: () => Promise }; + } +} + +const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); +const SNAP = { paused: false, halted: false, autoDeliveryPaused: false, gatedTools: [] as string[], pendingSteers: 0 }; +let snap = { ...SNAP }; +const cth = (window as unknown as { cth: Record }).cth; +// What main answers for the operator controls: the updated snapshot. +const controls: Record Promise> = { + controlSnapshot: () => Promise.resolve({ ...snap }), + controlPause: () => { snap = { ...snap, paused: true }; return Promise.resolve({ ...snap }); }, + controlResume: () => { snap = { ...snap, paused: false }; return Promise.resolve({ ...snap }); }, + controlHalt: () => { snap = { ...snap, halted: true }; return Promise.resolve({ ...snap }); }, + controlSteer: () => { snap = { ...snap, pendingSteers: snap.pendingSteers + 1 }; return Promise.resolve({ ...snap }); } +}; +(window as unknown as { cth: unknown }).cth = new Proxy(cth, { + get: (target, name: string) => controls[name] ?? (target as Record)[name] +}); + +const standIn = () => document.querySelector('[data-stand-in="terminal"]') as HTMLElement; +const height = () => Math.round(standIn().getBoundingClientRect().height * 100) / 100; +const button = (label: string) => [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === label) ?? null; +async function realClick(el: Element | null): Promise { + if (!el) return false; + const r = el.getBoundingClientRect(); + return window.harness.click(r.left + r.width / 2, r.top + r.height / 2); +} + +window.__harnessRun = async () => { + const steps: Array<{ step: string; height: number }> = []; + const at = (step: string) => steps.push({ step, height: height() }); + try { + const agent = { id: 'a1', name: 'Alice', status: 'working', ptyId: 'pty-a1', provider: 'codex' } as unknown as Agent; + useStore.setState({ messageQueues: { a1: [] } } as never); + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: false, capacityEvidence: null, interfered: null }; + createRoot(document.getElementById('root')!).render( + // AgentDetailPanel's column: the control strip, then the terminal area (terminal + + // composer), at the default sidebar width. +
+ +
+
+ +
+
+ ); + await sleep(600); + at('baseline'); + + await realClick(button('block tools')); await sleep(250); + at('block tools: flash showing'); + await sleep(2000); + at('block tools: flash gone'); + + await realClick(button('allow tools')); await sleep(250); + at('allow tools: flash showing'); + await sleep(2000); + + await realClick(button('stop after this step')); await sleep(250); + at('halt: flash + "stopping after this step" span'); + await sleep(2000); + at('halt: span only'); + + const input = [...document.querySelectorAll('input')].find((i) => i.placeholder?.startsWith('send this agent a note')) as HTMLInputElement; + const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + set.call(input, 'a note'); input.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(100); + await realClick([...document.querySelectorAll('button')].filter((b) => b.textContent?.trim() === 'send')[0]); + await sleep(250); + at('steer: flash + "1 note waiting"'); + await sleep(2000); + at('steer: "1 note waiting" only'); + + // The composer header: a queued message (count badge) under a hold with choices. + useStore.setState({ messageQueues: { a1: [{ id: 'm1', text: 'queued while busy', ts: 1 }] } } as never); + bridge.snapshot = { autoDeliveryPaused: false, capacityHold: false, capacityEvidence: null, + interfered: { requestId: 'queue:a1:m1', reason: 'HUMAN_INPUT_AFTER_STAGE', at: 1 } }; + await sleep(1500); + at('composer: queued + interfered choices'); + + window.harness.report({ ok: true, steps }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack ?? e), steps }); + } +}; diff --git a/test/electron-harness/scenarios/terminal-draft.ts b/test/electron-harness/scenarios/terminal-draft.ts new file mode 100644 index 0000000000..8ed809e3c4 --- /dev/null +++ b/test/electron-harness/scenarios/terminal-draft.ts @@ -0,0 +1,152 @@ +/** + * SCENARIO — can a rendered screen answer "the input box is empty AND nothing was + * submitted"? This is the observation `L0-TERMMATRIX` is blocked on. + * + * WHAT IS REAL HERE, because the answer is only worth what the instrument is: + * - a real Chromium renderer and a real layout engine + * - the REAL `terminalPool.ts` — `acquireTerminal`, `attachTerminal` and the + * `hasTerminalDraft` predicate under test, imported, not reimplemented + * - a real `@xterm/xterm` Terminal, really `open()`ed, so `entry.opened` is true + * and `buffer.active` holds a parsed screen rather than a plausible stub + * - the real keystroke path: `term.input()` fires the production `onData` + * handler, which is what maintains `lineBuf` and `inputDirty` + * + * WHAT IS SUBSTITUTED, and why it is the environment rather than the subject: + * - `window.cth`, the preload bridge. It is by definition the boundary between + * renderer and main, and the renderer cannot be loaded without one. Nothing + * under test reads it: `promptLineHasText` reads xterm's buffer, and the bytes + * that buffer holds are written here directly. No production file is modified + * and no production-only hook exists to make this observable. + * - the PTY. There is no child process, so nothing is echoed back; the TUI frames + * below are written straight into the terminal. That is the same stream a PTY + * would deliver, which is the point — the predicate reads the screen, and the + * screen does not know what produced it. + * + * WHAT THIS THEREFORE DOES AND DOES NOT SHOW. It shows the INSTRUMENT works: the + * predicate can be driven to both answers, and to its "don't know", against a real + * rendered screen. It does not show that any particular provider's TUI paints a box + * this parser reads correctly — that is the matrix itself, and it needs real + * captured output per provider, fed through this same path. + */ +import { + acquireTerminal, attachTerminal, hasTerminalDraft, disposeTerminal +} from '../../../src/renderer/src/components/terminalPool'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +/** The preload surface `terminalPool` touches. Inert, and recorded so a scenario + * cannot quietly depend on main doing something. */ +function installBridgeStub(): { writes: string[] } { + const writes: string[] = []; + const unsub = () => () => { /* no subscription to tear down */ }; + window.cth = { + onPtyData: unsub(), + onPtyExit: unsub(), + onPtyRelaunch: unsub(), + writePty: (_id: string, data: string) => { writes.push(data); }, + redrawPty: () => { /* no pty to redraw */ }, + resizePty: () => { /* no pty to resize */ }, + copyToClipboard: () => { /* not exercised */ }, + readClipboard: () => Promise.resolve(''), + readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), + revealPath: () => Promise.resolve() + }; + return { writes }; +} + +/** A TUI input box exactly like the ones these agents paint: a boxed prompt line, + * `PROMPT_CHROME` characters, and optionally the user's text inside it. */ +function promptFrame(text: string): string { + const inner = text.padEnd(38, ' '); + return [ + '\x1b[2J\x1b[H', + '╭──────────────────────────────────────╮\r\n', + `│ ❯ ${inner}│\r\n`, + '╰──────────────────────────────────────╯\r\n', + // Park the cursor back on the prompt row: `promptLineHasText` reads + // `baseY + cursorY`, so where the cursor sits IS which line is the prompt. + '\x1b[2;1H' + ].join(''); +} + +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); + +const ECHO_GRACE_MS = 1000; + +window.__harnessRun = async () => { + const result: Record = {}; + try { + const { writes } = installBridgeStub(); + const root = document.getElementById('root')!; + + // ── The state Andy is stuck in: a terminal that was never rendered ────────── + const detached = acquireTerminal('never-opened'); + detached.term.input('hello'); + const past = Date.now() + ECHO_GRACE_MS + 50; + result.unopened = { + opened: detached.opened, + everAttached: detached.everAttached, + // No rendered screen, so the screen cannot clear anything and the predicate + // falls back to the keystroke model. This is the reading that cannot + // distinguish an empty box from a full one. + hasDraft: hasTerminalDraft('never-opened', past) + }; + + // ── The same question, with a real rendered screen ────────────────────────── + const entry = acquireTerminal('rendered'); + attachTerminal(entry, root); + result.opened = entry.opened; + result.cols = entry.term.cols; + result.rows = entry.term.rows; + + // (1) THE PHANTOM DRAFT. The user pressed keys, so the keystroke model says + // dirty; the TUI swallowed them for its own UI, so the box is empty. This is + // the "box is empty AND nothing was submitted" reading. + entry.term.input('hello'); + await write(entry.term, promptFrame('')); + const now1 = Date.now() + ECHO_GRACE_MS + 50; + result.emptyBox = { + inputDirty: entry.inputDirty, + promptRow: readPromptRow(entry), + hasDraft: hasTerminalDraft('rendered', now1) + }; + + // (2) A REAL DRAFT. Same keystrokes, but the box shows them. + await write(entry.term, promptFrame('write the report')); + const now2 = Date.now() + ECHO_GRACE_MS + 50; + result.filledBox = { + inputDirty: entry.inputDirty, + promptRow: readPromptRow(entry), + hasDraft: hasTerminalDraft('rendered', now2) + }; + + // (3) THE ECHO WINDOW. Immediately after a keystroke the screen is showing the + // past, so it is not evidence and must not be allowed to clear the block. + entry.term.input('x'); + await write(entry.term, promptFrame('')); + result.insideEchoGrace = { hasDraft: hasTerminalDraft('rendered', Date.now()) }; + + result.bridgeWrites = writes.length; + disposeTerminal('never-opened'); + disposeTerminal('rendered'); + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; + +/** The exact row the predicate reads, returned so a failure shows what was on + * screen rather than only that a boolean disagreed. */ +function readPromptRow(entry: { term: { buffer: { active: any } } }): string | null { + const buf = entry.term.buffer.active; + const line = buf.getLine(buf.baseY + buf.cursorY); + return line ? line.translateToString(true) : null; +} diff --git a/test/electron-harness/scenarios/tui-clear-matrix.ts b/test/electron-harness/scenarios/tui-clear-matrix.ts new file mode 100644 index 0000000000..ce956e3680 --- /dev/null +++ b/test/electron-harness/scenarios/tui-clear-matrix.ts @@ -0,0 +1,210 @@ +/** + * SCENARIO — L0-TERMMATRIX. Does the candidate clear control actually empty a REAL + * provider TUI's input box? + * + * THE FRAMES BELOW ARE REAL CAPTURES, not hand-written TUI shapes. They were taken + * from each provider's own binary over a real PTY, in an ALREADY-TRUSTED cwd with + * the provider's real config, so no consent was given and none was asked. Enter was + * never sent. A provider whose first screen was not positively a composer was + * aborted WITHOUT A KEYSTROKE and has no row here — absence is UNKNOWN, never a pass. + * + * `antigravity` (agy) WAS such a provider, twice: it opened on "Do you trust the contents + * of this project?" and the tool, which never answers a dialog, aborted. Its row exists + * because the HUMAN then trusted one scratch folder by hand (C:/Dunder/_audit/ + * oscar-agy-scratch) and Oscar re-ran the same tool agy-only, four times. The entry below + * is trial 1 of his raw artifacts, byte-for-byte (C:/Dunder/hive/agents/oscar-mu3300lb/ + * agy-capture-trial-1.json); all four were replayed through THIS scenario before one was + * committed, and all four met every assertion. Different cwd from the claude/codex rows + * (C:/PrzEdit): that changes what the banner prints, not how the composer edits a line. + * + * Replay is the right instrument rather than a concession: a capture is a FIXTURE and + * re-runs identically, where a live TUI does not. The predicate reads xterm's buffer, + * and the screen does not know what produced its bytes. + * + * WHAT EACH ROW MUST SHOW, and why it takes two terminals: + * A. boot → empty → staged the marker is ON SCREEN (hasDraft true) + * + afterClear the box is EMPTY (hasDraft false) + * B. boot → empty → staged → afterClear → restaged staged again (hasDraft true) + * + afterNoop a HARMLESS key changes nothing + * Both branches are measured FROM A STAGED BASELINE. Measuring the two keys in + * sequence on one terminal lets the first key's repaint make the second look inert, + * which is an order artifact that happens to support the conclusion. + */ +import { + acquireTerminal, attachTerminal, hasTerminalDraft, disposeTerminal, readScreenForNeedle +} from '../../../src/renderer/src/components/terminalPool'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: unsub(), onPtyExit: unsub(), onPtyRelaunch: unsub(), + writePty: () => { /* no pty */ }, redrawPty: () => { /* no pty */ }, + resizePty: () => { /* no pty */ }, copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve() + }; +} + +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); + +const ECHO_GRACE_MS = 1000; + +/** xterm's own view of the modes this design depends on. `mouseTrackingMode` is + * the one that decides L0-FUSION 9.3 dimension 5; `bracketedPasteMode` is + * dimension 6; `sendFocusMode` matters because focus reports reach the SAME + * public onData a keystroke does, and are NOT human text. */ +function readModes(e: { term: { modes: { mouseTrackingMode: string; bracketedPasteMode: boolean; sendFocusMode: boolean } } }): { + mouseTrackingMode: string; bracketedPasteMode: boolean; sendFocusMode: boolean; +} { + const m = e.term.modes; + return { + mouseTrackingMode: m.mouseTrackingMode, + bracketedPasteMode: m.bracketedPasteMode, + sendFocusMode: m.sendFocusMode + }; +} + +/** Real per-provider captures. Deltas, in capture order: replaying them in + * sequence reconstructs the screen, because a terminal is a state machine. */ +const CAPTURES: Record = { + claude: { + mark: "ZQS44JHAQZ", + boot: "\u001b[2J\u001b[m\u001b[H\u001b]0;C:\\nvm4w\\nodejs\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe\u0007\u001b[?25h\u001b]0;claude\u0007\u001b[?2004h\u001b[?2031h\u001b[?1004h\u001b[?9001l\u001b[?25l\u001b]11;?\u0007\u001b[>0q\u001b[?u\u001b[>4m\u001b[?1004l\u001b[?2031l\u001b[?2004l\u001b[?2004h\u001b[?2031h\u001b[?1004h\u001b[?9001l\u001b]0;✳ Claude Code\u0007\u001b[38;2;215;119;87m ▐\u001b[48;2;0;0;0m▛███▛█\u001b[m \u001b[1m\u001b[97mClaude\u001b[m \u001b[1m\u001b[97mCode\u001b[m \u001b[38;2;102;102;102mv2.1.270\u001b[m\u001b[K\u001b[38;2;215;119;87m\r\n▝▜\u001b[48;2;0;0;0m█████\u001b[49m█▀\u001b[m \u001b[38;2;102;102;102mOpus\u001b[m \u001b[38;2;102;102;102m5\u001b[m \u001b[38;2;102;102;102m(1M\u001b[m \u001b[38;2;102;102;102mcontext)\u001b[m \u001b[38;2;102;102;102mwith\u001b[m \u001b[38;2;102;102;102mhigh\u001b[m \u001b[38;2;102;102;102meffort\u001b[m \u001b[38;2;102;102;102m·\u001b[m \u001b[38;2;102;102;102mClaude\u001b[m \u001b[38;2;102;102;102mMax\u001b[m\u001b[K\u001b[38;2;215;119;87m\r\n \u001b[m \u001b[38;2;215;119;87m▝▝\u001b[m \u001b[38;2;215;119;87m▝▝\u001b[m \u001b[38;2;102;102;102mC:\\PrzEdit\u001b[m\u001b[K\r\n\u001b[K\u001b[38;2;215;119;87m\r\n▎\u001b[m \u001b[1m\u001b[97mAuto\u001b[m \u001b[1m\u001b[97mmode\u001b[m \u001b[1m\u001b[97mis\u001b[m \u001b[1m\u001b[97mnow\u001b[m \u001b[1m\u001b[97mClaude\u001b[m \u001b[1m\u001b[97mCode's\u001b[m \u001b[1m\u001b[97mdefault\u001b[m \u001b[1m\u001b[97mpermission\u001b[m \u001b[1m\u001b[97mmode.\u001b[m\u001b[K\u001b[38;2;215;119;87m\r\n▎\u001b[m \u001b[38;2;102;102;102mAuto\u001b[m \u001b[38;2;102;102;102mmode\u001b[m \u001b[38;2;102;102;102mlets\u001b[m \u001b[38;2;102;102;102mClaude\u001b[m \u001b[38;2;102;102;102mhandle\u001b[m \u001b[38;2;102;102;102mpermission\u001b[m \u001b[38;2;102;102;102mprompts\u001b[m \u001b[38;2;102;102;102mautomatically.\u001b[m \u001b[38;2;102;102;102mClaude\u001b[m \u001b[38;2;102;102;102mchecks\u001b[m \u001b[38;2;102;102;102meach\u001b[m \u001b[38;2;102;102;102mtool\u001b[m \u001b[38;2;102;102;102mcall\u001b[m \u001b[38;2;102;102;102mfor\u001b[m \u001b[38;2;215;119;87m\r\n▎\u001b[m \u001b[38;2;102;102;102mrisky\u001b[m \u001b[38;2;102;102;102mactions\u001b[m \u001b[38;2;102;102;102mand\u001b[m \u001b[38;2;102;102;102mprompt\u001b[m \u001b[38;2;102;102;102minjection\u001b[m \u001b[38;2;102;102;102mbefore\u001b[m \u001b[38;2;102;102;102mexecuting,\u001b[m \u001b[38;2;102;102;102mruns\u001b[m \u001b[38;2;102;102;102mthe\u001b[m \u001b[38;2;102;102;102mones\u001b[m \u001b[38;2;102;102;102mit\u001b[m \u001b[38;2;102;102;102massesses\u001b[m \u001b[38;2;102;102;102mas\u001b[m \u001b[38;2;102;102;102mlower-risk,\u001b[m \u001b[38;2;102;102;102mand\u001b[m \u001b[38;2;215;119;87m\r\n▎\u001b[m \u001b[38;2;102;102;102mblocks\u001b[m \u001b[38;2;102;102;102mthe\u001b[m \u001b[38;2;102;102;102mrest.\u001b[m\u001b[K\u001b[38;2;215;119;87m\r\n▎\u001b[m \u001b[38;2;102;102;102mhttps://code.claude.com/docs/en/permission-modes\u001b[m\u001b[K\r\n\u001b[K\u001b[38;2;153;153;153m\r\n────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[m\r\n❯ Try \"fix lint errors\"\u001b[K\u001b[38;2;153;153;153m\r\n────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[m\r\n \u001b[38;2;150;108;30m⏵⏵\u001b[m \u001b[38;2;150;108;30mauto\u001b[m \u001b[38;2;150;108;30mmode\u001b[m \u001b[38;2;150;108;30mon\u001b[38;2;102;102;102m (shift+tab\u001b[m \u001b[38;2;102;102;102mto\u001b[m \u001b[38;2;102;102;102mcycle)\u001b[m \u001b[38;2;102;102;102m·\u001b[m \u001b[38;2;102;102;102m←\u001b[m \u001b[38;2;102;102;102mfor\u001b[m \u001b[38;2;102;102;102magents\u001b[m\u001b[29X\u001b[38;2;102;102;102m\u001b[29C●\u001b[m \u001b[38;2;102;102;102mhigh\u001b[m \u001b[38;2;102;102;102m·\u001b[m \u001b[38;2;102;102;102m/effort\u001b[m \r\n\u001b[83X\u001b[38;2;150;108;30m\u001b[83C/rc\u001b[m \u001b[38;2;150;108;30mconnecting…\u001b[m \u001b[12;3H\u001b[?25h\u001b[25l\u001b[>0q\u001b[15;84H\u001b[12X\u001b[38;2;44;122;57m\u001b[12C/rc\u001b[12;3H\u001b[?25h\u001b[m", + empty: "", + staged: "\u001b[25lZQS44JHAQZ\u001b[K\u001b[38;2;153;153;153m\u001b[13;3H──────────────────────────────────────────────────────────────────────────────────────────────────\u001b[38;2;150;108;30m\u001b[14;3H⏵⏵\u001b[m \u001b[38;2;150;108;30mauto\u001b[m \u001b[38;2;150;108;30mmode\u001b[m \u001b[38;2;150;108;30mon\u001b[38;2;102;102;102m (shift+tab\u001b[m \u001b[38;2;102;102;102mto\u001b[m \u001b[38;2;102;102;102mcycle)\u001b[m\u001b[15X\u001b[15C\u001b[29X\u001b[38;2;102;102;102m\u001b[29C●\u001b[m \u001b[38;2;102;102;102mhigh\u001b[m \u001b[38;2;102;102;102m·\u001b[m \u001b[38;2;102;102;102m/effort\u001b[m \u001b[12;13H\u001b[?25h", + afterClear: "\u001b[25l\u001b[12;3HTry \"fix lint errors\"\u001b[74X\u001b[38;2;153;153;153m\u001b[13;3H───────────────────────────────────────────────────────────────────────────────────────────────\u001b[38;2;150;108;30m\u001b[14;3H⏵⏵\u001b[m \u001b[38;2;150;108;30mauto\u001b[m \u001b[38;2;150;108;30mmode\u001b[m \u001b[38;2;150;108;30mon\u001b[38;2;102;102;102m (shift+tab\u001b[m \u001b[38;2;102;102;102mto\u001b[m \u001b[38;2;102;102;102mcycle) · ← for agents\u001b[m\u001b[17X\u001b[38;2;102;102;102m\u001b[17CCtrl+Y to paste deleted tex\u001b[12;3H\u001b[?25h\u001b[m", + restaged: "\u001b[25lZQS44JHAQZ\u001b[K\u001b[38;2;153;153;153m\u001b[13;3H──────────────────────────────────────────────────────────────────────────────────────────────────\u001b[38;2;150;108;30m\u001b[14;3H⏵⏵\u001b[m \u001b[38;2;150;108;30mauto\u001b[m \u001b[38;2;150;108;30mmode\u001b[m \u001b[38;2;150;108;30mon\u001b[38;2;102;102;102m (shift+tab\u001b[m \u001b[38;2;102;102;102mto\u001b[m \u001b[38;2;102;102;102mcycle)\u001b[m\u001b[15X\u001b[15C\u001b[17X\u001b[38;2;102;102;102m\u001b[17CCtrl+Y to paste deleted text\u001b[m \u001b[12;13H\u001b[?25h", + afterNoop: "" + }, + codex: { + mark: "ZQTYUG8NQZ", + boot: "\u001b[?2004h\u001b[?1004l\u001b[?2026h\u001b[2J\u001b[m\u001b[H\u001b]0;C:\\nvm4w\\nodejs\\node.exe\u0007\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l╭───────────────────────────────────────╮\u001b[K\r\n│ >_ \u001b[1m\u001b[97mOpenAI Codex\u001b[m (v0.154.0) │\u001b[K\r\n│ │\u001b[K\r\n│ model: \u001b[3mloading\u001b[23m \u001b[36m/model\u001b[m to change │\u001b[K\r\n│ directory: C:\\PrzEdit │\u001b[K\r\n╰───────────────────────────────────────╯\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\u001b[97m\r\n›\u001b[m Ask Codex to do anything\u001b[K\r\n\u001b[K\r\n ? for shortcuts\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[9;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[H╭\u001b[9;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l╭\u001b[9;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[H╭\u001b[9;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b]10;?\u001b\\\u001b]11;?\u001b\\╭\u001b[9;3H\u001b]0;PrzEdit\u0007\u001b[?25h\u001b[25l\u001b[?2026h\u001b[38;2;246;226;183m\u001b[11;3Hgpt-6-astra default\u001b[m · \u001b[38;2;171;223;167mC:\\PrzEdit\u001b[H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\u001b[m╭\u001b[9;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l╭\u001b[9;3H\u001b[?25h\u001b]0;⠹ PrzEdit\u0007\u001b]0;⠸ PrzEdit\u0007\u001b[25l\u001b[?2026h\u001b[H\u001b[K\r\n╭────────────────────────────────────────────────╮\u001b[K\r\n│ >_ \u001b[1m\u001b[97mOpenAI Codex\u001b[m (v0.154.0) │\u001b[K\r\n│ │\u001b[K\r\n│ model: gpt-6-astra high \u001b[36m/model\u001b[m to change │\u001b[K\r\n│ directory: C:\\PrzEdit │\u001b[K\r\n╰────────────────────────────────────────────────╯\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[8;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[H╭────────────────────────────────────────────────╮\u001b[K\r\n│ >_ \u001b[1m\u001b[97mOpenAI Codex\u001b[m (v0.154.0) │\u001b[K\r\n│ │\u001b[K\r\n│ model: gpt-6-astra high \u001b[36m/model\u001b[m to change │\u001b[K\r\n│ directory: C:\\PrzEdit │\u001b[K\r\n╰────────────────────────────────────────────────╯\u001b[K\r\n\u001b[K\r\n \u001b[1m\u001b[97mTip:\u001b[m Try the \u001b[1m\u001b[97mDesktop app\u001b[m. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true\r\n\u001b[K\r\n\u001b[K\u001b[1m\u001b[97m\r\n›\u001b[m Ask Codex to do anything\u001b[K\r\n\u001b[K\r\n \u001b[38;2;246;226;183mgpt-6-astra high\u001b[m · \u001b[38;2;171;223;167mC:\\PrzEdit\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[11;3H\u001b[?25h\u001b]0;⠼ PrzEdit\u0007\u001b]0;⠴ PrzEdit\u0007\u001b[25l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[9;1H \u001b[11;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[9;1H\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\u001b[97m\r\n›\u001b[m Ask Codex to do anything\u001b[K\r\n\u001b[K\r\n \u001b[38;2;246;226;183mgpt-6-astra high\u001b[m · \u001b[38;2;171;223;167mC:\\PrzEdit\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[8;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l\r\n\u001b[K\r\n• You have 1 usage limit reset available. Run /usage to use one.\u001b[K\r\n\u001b[K\u001b[33m\r\n⚠ Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.\u001b[m\u001b[K\r\n\u001b[K\u001b[33m\r\n⚠ Heads up, you have less than 50% of your 5h limit left. Run /status for a breakdown.\u001b[m\u001b[K\r\n\u001b[K\u001b[17;3H\u001b[?25h\u001b]0;⠦ PrzEdit\u0007\u001b]0;PrzEdit\u0007\u001b[25l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[15;1H \u001b[17;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[0 q\u001b[?2026l\u001b[15;1H \u001b[17;3H\u001b[?25h\u001b[25l\u001b[?2026h\u001b[15;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l \u001b[17;3H\u001b[?25h", + empty: "", + staged: "\u001b[25l\u001b[?2026hZQTYUG8NQZ\u001b[K\u001b[15;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l \u001b[17;13H\u001b[?25h", + afterClear: "\u001b[25l\u001b[?2026h\u001b[17;3HAsk Codex to do anything\u001b[15;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l \u001b[17;3H\u001b[?25h", + restaged: "\u001b[25l\u001b[?2026hZQTYUG8NQZ\u001b[K\u001b[15;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l \u001b[17;13H\u001b[?25h", + afterNoop: "\u001b[25l\u001b[?2026h\u001b[15;1H\u001b[?25h\u001b[25l\u001b[0 q\u001b[?2026l \u001b[17;13H\u001b[?25h" + }, + antigravity: { + mark: "ZQSSSW5FQZ", + boot: "\u001b[?2026$p\u001b[?2027$p\u001b[>4m\u001b[?5W\u001b[?2004h\u001b[>4;2m\u001b[>1u\u001b[?u\u001b[2J\u001b[m\u001b[H\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\u001b]0;C:\\Users\\FiercePC\\AppData\\Local\\agy\\bin\\agy.exe\u0007\u001b[H\u001b[K\r\n \u2584\u2580\u2580\u2584\u001b[K\r\n \u2580\u2580\u2580\u2580\u2580\u2580\u001b[K\r\n \u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u001b[K\r\n \u2584\u2580\u2580 \u2580\u2580\u2584\u001b[K\r\n \u2584\u2580\u2580 \u2580\u2580\u2584\u001b[K\r\n\u001b[K\r\n Welcome to the Antigravity CLI. You are currently not signed in.\u001b[K\r\n\u001b[K\r\n \u28f7 Signing in...\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[100C\u001b[>4m\u001b[<1u\u001b[>4;2m\u001b[>1u\u001b[?u\u001b[0 q\u001b[H\u001b[K\r\n \u2584\u2580\u2580\u2584 \u001b[1m\u001b[97mAntigravity CLI 1.2.7\u001b[m\u001b[K\r\n \u2580\u2580\u2580\u2580\u2580\u2580 agyuser00@example.com\u001b[K\r\n \u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580 C:/Dunder/_audit/oscar-agy-scratch\u001b[K\r\n \u2584\u2580\u2580 \u2580\u2580\u2584\u001b[K\r\n \u2584\u2580\u2580 \u2580\u2580\u2584\u001b[K\r\n\u001b[K\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n>\u001b[K\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n? for shortcuts\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[9;3H\u001b[?25h\u001b[25l\u001b[4;17H Gemini 3.8 Flash (Medium)\u001b[K\u001b[5;17H C:/Dunder/_audit/oscar-agy-scratch\u001b[K\u001b[6;17H\u001b[K\n\u001b[K\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[9;17H\u001b[K\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u001b[11;17H\u001b[59X\u001b[59CGemini 3.8 Flash \u00b7 medium\u001b[9;3H\u001b[?25h\u001b[25l\u001b[3;40H (Google AI Pro)\u001b[9;3H\u001b[?25h", + empty: "", + staged: "Z\u001b[25l\r> ZQS\u001b[71X\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n G\u001b[9;6H\u001b[?25hSSW5FQZ", + afterClear: "\u001b[25l\r> \u001b[K\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n? for shortcuts Gemini 3.8 Flash \u00b7 medium\u001b[9;3H\u001b[?25h", + restaged: "\u001b[25l\r> Z\u001b[73X\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n G\u001b[9;4H\u001b[?25hQSSSW5FQZ", + afterNoop: "" + } +}; + +/** The row the production predicate reads: baseY + cursorY. Where the cursor sits + * IS which line is the prompt, so this is the prompt line and not a guess. */ +function promptRow(entry: { term: { buffer: { active: any } } }): string { + const buf = entry.term.buffer.active; + const line = buf.getLine(buf.baseY + buf.cursorY); + return line ? line.translateToString(true) : ''; +} + +/** The whole screen, so a failure shows what was rendered and not only a boolean. */ +function screen(entry: { term: { buffer: { active: any }; rows: number } }): string[] { + const buf = entry.term.buffer.active; + const out: string[] = []; + for (let y = 0; y < entry.term.rows; y += 1) { + const line = buf.getLine(buf.baseY + y); + const s = line ? line.translateToString(true) : ''; + if (s.trim()) out.push(s); + } + return out; +} + +window.__harnessRun = async () => { + const result: Record = { providers: {} }; + try { + installBridgeStub(); + const root = document.getElementById('root')!; + + for (const [name, cap] of Object.entries(CAPTURES)) { + // ---- branch A: staged, then the candidate clear + const a = acquireTerminal('A-' + name); + attachTerminal(a, root); + await write(a.term, cap.boot); + // L0-FUSION 9.3 dimension 5: can this TUI generate mouse-origin terminal + // input at all? Read from xterm's OWN public `modes`, never from a regex of + // mine over the DECSET bytes - `modes.mouseTrackingMode` is derived from + // `coreMouseService.activeProtocol` (browser/public/Terminal.ts:108-113), so + // asking the terminal is asking the thing that actually decides. + const modesAtBoot = readModes(a); + await write(a.term, cap.empty); + await write(a.term, cap.staged); + const stagedNow = Date.now() + ECHO_GRACE_MS + 50; + const stagedScreen = screen(a); + const stagedHasDraft = hasTerminalDraft('A-' + name, stagedNow); + const stagedShowsMark = stagedScreen.some((l) => l.includes(cap.mark)); + const stagedPromptRow = promptRow(a); + // L0-FUSION stage 5.4c: THE PRODUCTION ERASE ORACLE on the same rendered screen. + // `promptRow` / `screen` above are this scenario's own reading; this is the function + // main's submit owner actually asks, read beside them so the two can be compared. + const stagedOracle = readScreenForNeedle('A-' + name, cap.mark); + + await write(a.term, cap.afterClear); + const clearedNow = Date.now() + ECHO_GRACE_MS + 50; + const clearedScreen = screen(a); + const clearedHasDraft = hasTerminalDraft('A-' + name, clearedNow); + const clearedShowsMark = clearedScreen.some((l) => l.includes(cap.mark)); + const clearedPromptRow = promptRow(a); + const clearedOracle = readScreenForNeedle('A-' + name, cap.mark); + + // ---- branch B: staged again, then a harmless key + const b = acquireTerminal('B-' + name); + attachTerminal(b, root); + await write(b.term, cap.boot); + await write(b.term, cap.empty); + await write(b.term, cap.staged); + await write(b.term, cap.afterClear); + await write(b.term, cap.restaged); + const reNow = Date.now() + ECHO_GRACE_MS + 50; + const reHasDraft = hasTerminalDraft('B-' + name, reNow); + const reShowsMark = screen(b).some((l) => l.includes(cap.mark)); + + await write(b.term, cap.afterNoop); + const noopNow = Date.now() + ECHO_GRACE_MS + 50; + const noopScreen = screen(b); + const noopHasDraft = hasTerminalDraft('B-' + name, noopNow); + const noopShowsMark = noopScreen.some((l) => l.includes(cap.mark)); + const noopPromptRow = promptRow(b); + const noopOracle = readScreenForNeedle('B-' + name, cap.mark); + const absentOracle = readScreenForNeedle('B-' + name, 'NEVER-STAGED-' + cap.mark); + const emptyNeedleOracle = readScreenForNeedle('B-' + name, ''); + // Read again at the end: a mode set later would be just as fatal as one set + // at boot, and a single reading cannot tell "never" from "not yet". + const modesAtEnd = readModes(a); + + (result.providers as Record)[name] = { + opened: a.opened && b.opened, + oracle: { staged: stagedOracle, afterClear: clearedOracle, afterNoop: noopOracle, absent: absentOracle, emptyNeedle: emptyNeedleOracle, + stagedScreenCount: stagedScreen.filter((l) => l.includes(cap.mark)).length }, + modes: { atBoot: modesAtBoot, atEnd: modesAtEnd }, + staged: { hasDraft: stagedHasDraft, showsMark: stagedShowsMark, onPromptRow: stagedPromptRow.includes(cap.mark), promptRow: stagedPromptRow, screen: stagedScreen.slice(-6) }, + afterClear: { hasDraft: clearedHasDraft, showsMark: clearedShowsMark, onPromptRow: clearedPromptRow.includes(cap.mark), promptRow: clearedPromptRow, screen: clearedScreen.slice(-6) }, + restaged: { hasDraft: reHasDraft, showsMark: reShowsMark }, + afterNoop: { hasDraft: noopHasDraft, showsMark: noopShowsMark, onPromptRow: noopPromptRow.includes(cap.mark), promptRow: noopPromptRow, screen: noopScreen.slice(-6) } + }; + disposeTerminal('A-' + name); + disposeTerminal('B-' + name); + } + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/tui-preconditions.ts b/test/electron-harness/scenarios/tui-preconditions.ts new file mode 100644 index 0000000000..03e623460b --- /dev/null +++ b/test/electron-harness/scenarios/tui-preconditions.ts @@ -0,0 +1,206 @@ +/** + * SCENARIO — the two preconditions that make a TUI measurement mean anything. + * + * BOTH WERE PAID FOR BY A VOID RUN. Andy's whole clear/erase matrix was discarded + * because every row measured a first-run dialog instead of an input box, and because + * a "reaction" to Ctrl-U turned out to be the same repaint frame a harmless arrow key + * produces. Neither failure announces itself: both produce a PASSING row. So they + * belong in the instrument, where they cost one run to find and nothing after. + * + * 1. THE SCREEN MUST BE POSITIVELY IDENTIFIED AS A COMPOSER, OR THE MEASUREMENT + * IS REFUSED. See `isMeasurableComposer` for why this is not a list of dialogs. + * 2. A FALSIFIER WITH NO CONTROL PASSES ON EVERYTHING. Any claim that a key did + * something must be paired with a key that should do nothing; if the two + * reactions are identical, what was seen is a repaint, not an effect. + * + * Every non-composer frame below is REAL BYTES, captured by Andy from the installed + * TUIs with isolated provider homes and quoted rather than invented. + */ +import { + acquireTerminal, attachTerminal, hasTerminalDraft, disposeTerminal, + terminalAutomationBlockFor +} from '../../../src/renderer/src/components/terminalPool'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + cth: Record; + } +} + +function installBridgeStub(): void { + const unsub = () => () => { /* nothing subscribed */ }; + window.cth = { + onPtyData: unsub(), onPtyExit: unsub(), onPtyRelaunch: unsub(), + writePty: () => { /* no pty */ }, redrawPty: () => { /* no pty */ }, + resizePty: () => { /* no pty */ }, copyToClipboard: () => { /* unused */ }, + readClipboard: () => Promise.resolve(''), readClipboardSync: () => '', + statAbs: () => Promise.resolve(null), revealPath: () => Promise.resolve() + }; +} + +const write = (term: { write: (d: string, cb?: () => void) => void }, data: string): Promise => + new Promise((resolve) => term.write(data, () => resolve())); + +const clear = '\x1b[2J\x1b[H'; + +/** + * NON-COMPOSER SCREENS, REAL BYTES. Which one a fresh start lands on depends on + * WHICH THING IS FRESH: a fresh cwd with an existing config gives the trust modal; + * a fresh CONFIG gives onboarding, which is several screens. + * + * These are fixtures for the refusal arms. They are deliberately NOT the detector's + * input - nothing below is matched against this list. + */ +const NON_COMPOSER: Record = { + 'claude trust': `${clear} Do you trust the files in this folder?\r\n\r\n Yes, I trust this folder\r\n Enter to confirm - Esc to cancel\r\n`, + 'codex trust': `${clear} You are running Codex in a new folder.\r\n\r\n 1. Yes, continue\r\n 2. No, quit\r\n`, + 'agy trust': `${clear} Trust this workspace?\r\n\r\n Yes, I trust this folder\r\n No\r\n`, + 'claude onboarding theme': `${clear} Choose the text style that looks best with your terminal\r\n\r\n 1. Auto (match terminal)\r\n 2. Dark mode\r\n 3. Light mode\r\n 4. Dark mode (colorblind-friendly)\r\n\r\n To change this later, run /theme\r\n`, + 'claude onboarding login': `${clear} Select login method:\r\n\r\n 1. Claude account with subscription\r\n 2. Anthropic Console account\r\n 3. 3rd-party platform\r\n`, + 'codex sign-in': `${clear} Sign in with ChatGPT to use Codex as part of your paid plan\r\n\r\n 1. Sign in with ChatGPT\r\n 2. Sign in with Device Code\r\n 3. Provide your own API key\r\n\r\n Press enter to continue\r\n`, + 'agy onboarding theme': `${clear} solarized dark\r\n colorblind-friendly dark\r\n tokyo night\r\n`, + // THE ONE THAT IS DIFFERENT IN KIND. Enter on a trust modal consents to a folder. + // Enter on this one ACCEPTS A TERMS OF SERVICE AND A DATA-COLLECTION AGREEMENT ON + // THE HUMAN'S BEHALF. It is the reason the detector must fail closed. + 'agy consent': `${clear} ...to collect and use my Interactions data, subject to the\r\n Google Antigravity CLI Terms of Service and Google Privacy Policy\r\n\r\n [Previous] [Done]\r\n\r\n Navigate - enter Toggle\r\n`, + // Claude Code 2.1.283: an already connected resumed Remote Control session opens + // this dialog when `/remote-control` is typed again. The delivery block recognizes + // these stable labels rather than treating every unknown dialog as a picker. + 'claude remote-control reconnect': `${clear} Remote Control is already connected\r\n\r\n ❯ 1. Disconnect this session\r\n 2. Show QR code\r\n 3. Continue\r\n`, + 'claude remote-control reconnect qr visible': `${clear} Remote Control is already connected\r\n\r\n ❯ 1. Disconnect this session\r\n 2. Hide QR code\r\n 3. Continue\r\n`, + 'claude remote-control reconnect bordered': `${clear} ┃ ❯ 1. Disconnect this session ┃\r\n┃ 2. Show QR code ┃\r\n┃ 3. Continue ┃\r\n`, + // NOT A REAL SCREEN, AND THAT IS ITS ENTIRE PURPOSE. Nobody has enumerated this + // one, so it is the arm that proves the detector fails CLOSED rather than open. + 'a screen nobody has met': `${clear} Select your region\r\n\r\n 1. United States\r\n 2. European Union\r\n 3. Other\r\n` +}; + +/** An ordinary prompt box - the thing a measurement is actually entitled to read. */ +const composerFrame = (text: string): string => [ + clear, + '╭──────────────────────────────────────╮\r\n', + `│ ❯ ${text.padEnd(35, ' ')}│\r\n`, + '╰──────────────────────────────────────╯\r\n', + '\x1b[2;1H' +].join(''); + +/** + * Is the cursor sitting on something that is POSITIVELY a composer input line? + * + * THIS IS AN ALLOWLIST OF ONE SHAPE, NOT A BLOCKLIST OF DIALOGS, AND THE INVERSION + * IS THE WHOLE POINT. The first version of this function enumerated the trust + * screens it knew about and measured anything else. Andy's capture showed there are + * at least four non-composer states rather than one, and which you land on depends + * on whether the CWD or the CONFIG is fresh - but the count is not the argument. + * The argument is that A DETECTOR THAT ENUMERATES WHAT TO REFUSE FAILS OPEN ON + * EVERYTHING IT HAS NOT MET, so the list is always one screen behind the installer. + * + * WHAT THAT COSTS IS NOT A VOID ROW. Andy's driver met the Antigravity consent + * screen, where Enter accepts a Terms of Service and a data-collection agreement on + * the human's behalf. A blocklist has no entry for a screen nobody has seen, so it + * would have measured it - and a measurement here means pressing keys. + * + * So: refuse by default, and admit only what is recognised. The same asymmetry + * `promptLineHasText` already uses, where "don't know" keeps the block rather than + * clearing it - a wrong refusal costs a re-run, a wrong admission presses Enter on + * a contract. + */ +function isMeasurableComposer(screen: string, cursorRow: string): boolean { + // A composer offers a place to TYPE: a prompt marker on the row the cursor is on. + // A chooser offers a place to PICK, and puts the cursor on a list item. + const marker = /[❯>$#▌]/u; + if (!marker.test(cursorRow)) return false; + // And that row belongs to an input region rather than being prose that happens to + // contain a bracket: either boxed, or the marker leads the line. + const boxed = /[│┃|]/u.test(cursorRow); + const leads = /^\s*[❯>$#▌]/u.test(cursorRow); + if (!boxed && !leads) return false; + // A composer is a single input affordance. A screen that also presents a numbered + // or bracketed choice is asking a question, whatever else is on it. + if (/^\s*\d+\.\s+\S/mu.test(screen)) return false; + if (/\[\s*(Done|Previous|Next|Continue|Cancel)\s*\]/iu.test(screen)) return false; + return true; +} + +function screenOf(entry: { term: { rows: number; buffer: { active: any } } }): { full: string; cursorRow: string } { + const buf = entry.term.buffer.active; + const rows: string[] = []; + for (let y = 0; y < entry.term.rows; y++) { + const line = buf.getLine(buf.baseY + y); + if (line) rows.push(line.translateToString(true)); + } + const cur = buf.getLine(buf.baseY + buf.cursorY); + return { full: rows.join('\n'), cursorRow: cur ? cur.translateToString(true) : '' }; +} + +window.__harnessRun = async () => { + const result: Record = {}; + try { + installBridgeStub(); + const root = document.getElementById('root')!; + const entry = acquireTerminal('tui'); + attachTerminal(entry, root); + + // ── 1. Every non-composer state must be REFUSED, named or not ───────────── + const refusals: Record = {}; + for (const [name, frame] of Object.entries(NON_COMPOSER)) { + await write(entry.term, frame); + const { full, cursorRow } = screenOf(entry); + refusals[name] = { + measurable: isMeasurableComposer(full, cursorRow), + // What a measurement WOULD have reported had it not refused. Recorded so + // the cost of skipping the precondition is visible rather than argued. + naiveHasDraft: hasTerminalDraft('tui', Date.now() + 5_000), + // AND THE PRODUCTION GAP: nothing in the app knows a modal owns the screen. + // `opensInteractiveTerminalUi` matches what the USER TYPED against a set of + // bare slash-commands; these screens are opened by the PROGRAM, so no input + // passes through that check and the picker block cannot latch. + automationBlock: terminalAutomationBlockFor('tui', Date.now() + 5_000) ?? null + }; + } + result.nonComposer = refusals; + + // ── And a real composer must still be measurable, empty or full ─────────── + await write(entry.term, composerFrame('')); + const empty = screenOf(entry); + await write(entry.term, composerFrame('write the report')); + const filled = screenOf(entry); + result.composer = { + emptyMeasurable: isMeasurableComposer(empty.full, empty.cursorRow), + filledMeasurable: isMeasurableComposer(filled.full, filled.cursorRow) + }; + + // ── 2. The control ──────────────────────────────────────────────────────── + const REPAINT = '\x1b[2;1H\x1b[K│ ❯ │'; + /** + * EACH REACTION IS MEASURED FROM THE SAME BASELINE, and that is not fussiness. + * Measuring them in sequence made the SECOND key look inert - the first had + * already applied the repaint, so the screen no longer moved. Read carelessly + * that says "the arrow did nothing, so Ctrl-U's change was real", which is the + * exact wrong conclusion the control exists to prevent. An order artifact that + * happens to support the conclusion is worse than no control at all. + */ + const reactionTo = async (key: string) => { + await write(entry.term, composerFrame('')); + const before = screenOf(entry).full; + entry.term.input(key); + await write(entry.term, REPAINT); // what this TUI emits for ANY key + const after = screenOf(entry).full; + return { changed: after !== before, screen: after }; + }; + const ctrlU = await reactionTo('\x15'); + const arrow = await reactionTo('\x1b[C'); // right arrow: should do nothing + result.control = { + ctrlUChanged: ctrlU.changed, + arrowChanged: arrow.changed, + identicalReaction: ctrlU.screen === arrow.screen, + verdict: ctrlU.screen === arrow.screen ? 'REPAINT_NOT_EFFECT' : 'REAL_EFFECT' + }; + + disposeTerminal('tui'); + window.harness.report({ ok: true, ...result }); + } catch (e) { + window.harness.report({ ok: false, error: String((e as Error)?.stack || e), partial: result }); + } +}; diff --git a/test/electron-harness/scenarios/update-picker.tsx b/test/electron-harness/scenarios/update-picker.tsx new file mode 100644 index 0000000000..945710d1ae --- /dev/null +++ b/test/electron-harness/scenarios/update-picker.tsx @@ -0,0 +1,130 @@ +/** + * SCENARIO - UPDATER-ALL-VERSIONS. On 1.1.59 with 1.1.60, 1.1.61 and 1.1.62 published, + * do the PRODUCTION title-bar badge and Settings -> Updates offer all three, latest + * preselected, with each version's own notes, and download the one picked? + * + * A. badge: one click opens the picker (not a download); 3 options, latest selected, + * latest's notes shown; picking 1.1.60 swaps the notes; download opens 1.1.60's + * installer URL, nothing else. + * B. Settings, native 'available': latest selected -> primary button is the native + * "Download v1.1.62" (updateDownload); picking 1.1.61 turns it into a manual + * "Download v1.1.61 installer" that opens 1.1.61's installer and never calls + * updateDownload. + * C. a single newer version keeps the old one-click badge download (no picker). + */ +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { UpdateBadge } from '../../../src/renderer/src/components/UpdateBadge'; +import { UpdatesSection } from '../../../src/renderer/src/components/UpdatesSection'; + +declare global { + interface Window { + __harnessRun: () => Promise; + harness: { report: (payload: unknown) => void }; + } +} +(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = '1.1.59'; + +const REPO = 'Prozzta/hornham-wegg'; +const exe = (v: string) => `https://github.com/${REPO}/releases/download/v${v}/Munder-Difflin-${v}-win-x64-setup.exe`; +const opt = (v: string) => ({ version: v, url: `https://github.com/${REPO}/releases/tag/v${v}`, downloadUrl: exe(v), notes: `## What's new in ${v}\n\n- only in ${v}` }); + +let current: unknown = null; +const opened: string[] = []; +let downloads = 0; +(window as unknown as { cth: unknown }).cth = new Proxy({}, { + get: (_t, name: string) => { + if (name === 'platform') return 'win32'; + if (name === 'arch') return 'x64'; + if (name === 'updateCurrent') return () => Promise.resolve(current); + if (name === 'updateOpenRelease') return (u: string) => { opened.push(u); return Promise.resolve({ ok: true }); }; + if (name === 'updateDownload') return () => { downloads += 1; return Promise.resolve({ ok: true }); }; + if (name.startsWith('on')) return () => () => {}; + return () => Promise.resolve(undefined); + } +}); + +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 30)); }); +const text = (el: Element | null) => (el?.textContent ?? '').replace(/\s+/g, ' ').trim(); +function choose(sel: HTMLSelectElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(sel, value); + sel.dispatchEvent(new Event('change', { bubbles: true })); +} +const buttonNamed = (host: Element, re: RegExp) => + Array.from(host.querySelectorAll('button')).find((b) => re.test(text(b))) as HTMLButtonElement | undefined; + +async function mount(el: JSX.Element) { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { root.render(el); }); + await settle(); + return { host, unmount: () => act(async () => { root.unmount(); host.remove(); }) }; +} + +window.__harnessRun = async () => { + try { + const three = [opt('1.1.62'), opt('1.1.61'), opt('1.1.60')]; + + // A. badge (manual path) + current = { state: 'available-manual', version: '1.1.62', url: three[0].url, downloadUrl: exe('1.1.62'), notes: three[0].notes, versions: three }; + const a = await mount(); + const chip = a.host.querySelector('button')!; + const chipLabel = text(chip); + await act(async () => { chip.click(); }); + await settle(); + const openedAfterChipClick = opened.length; + const sel = a.host.querySelector('select') as HTMLSelectElement | null; + const options = sel ? Array.from(sel.options).map((o) => text(o)) : []; + const preselected = sel?.value ?? null; + const notesLatest = text(a.host.querySelector('[role="dialog"] ul')); + if (sel) await act(async () => { choose(sel, '1.1.60'); }); + await settle(); + const notesPicked = text(a.host.querySelector('[role="dialog"] ul')); + const dl = buttonNamed(a.host, /^download v/); + const dlLabel = text(dl ?? null); + await act(async () => { dl?.click(); }); + await settle(); + const badgeOpened = opened.slice(openedAfterChipClick); + await a.unmount(); + + // B. Settings (native path) + opened.length = 0; + current = { state: 'available', version: '1.1.62', notes: three[0].notes, versions: three.map(({ downloadUrl: _d, ...r }) => r) }; + const b = await mount(); + const bSel = b.host.querySelector('select') as HTMLSelectElement | null; + const nativeBtn = text(buttonNamed(b.host, /^Download v1\.1\.62$/) ?? null); + await act(async () => { buttonNamed(b.host, /^Download v1\.1\.62$/)?.click(); }); + await settle(); + const downloadsAfterNative = downloads; + if (bSel) await act(async () => { choose(bSel, '1.1.61'); }); + await settle(); + const olderBtn = buttonNamed(b.host, /installer$/); + const olderLabel = text(olderBtn ?? null); + const settingsNotes = text(b.host.querySelector('ul')); + await act(async () => { olderBtn?.click(); }); + await settle(); + const settingsOpened = opened.slice(); + const downloadsAfterOlder = downloads; + await b.unmount(); + + // C. one newer version: the old one-click download + opened.length = 0; + current = { state: 'available-manual', version: '1.1.60', url: three[2].url, downloadUrl: exe('1.1.60'), notes: three[2].notes }; + const c = await mount(); + await act(async () => { c.host.querySelector('button')!.click(); }); + await settle(); + const singleHasSelect = !!c.host.querySelector('select'); + const singleOpened = opened.slice(); + await c.unmount(); + + window.harness.report({ + ok: true, + badge: { chipLabel, openedAfterChipClick, options, preselected, notesLatest, notesPicked, dlLabel, opened: badgeOpened }, + settings: { hasSelect: !!bSel, nativeBtn, downloadsAfterNative, olderLabel, settingsNotes, opened: settingsOpened, downloadsAfterOlder }, + single: { hasSelect: singleHasSelect, opened: singleOpened } + }); + } catch (e) { + window.harness.report({ ok: false, error: e instanceof Error ? `${e.message}\n${e.stack}` : String(e) }); + } +}; diff --git a/test/fixtures/codex-debug-models-0.157.1.json b/test/fixtures/codex-debug-models-0.157.1.json new file mode 100644 index 0000000000..9cafe56afd --- /dev/null +++ b/test/fixtures/codex-debug-models-0.157.1.json @@ -0,0 +1 @@ +{"models":[{"slug":"gpt-6-astra","display_name":"GPT-6-Astra","description":"Frontier intelligence for the most demanding work.","visibility":"list","priority":1,"supported_in_api":true},{"slug":"gpt-6-sol","display_name":"GPT-6-Sol","description":"Workhorse model for coding and everyday work.","visibility":"list","priority":2,"supported_in_api":true},{"slug":"gpt-6-luna","display_name":"GPT-6-Luna","description":"Fast and affordable model for easier tasks.","visibility":"list","priority":3,"supported_in_api":true},{"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Older coding model for complex work.","visibility":"list","priority":4,"supported_in_api":true},{"slug":"gpt-5.6-terra","display_name":"GPT-5.6-Terra","description":"Older balanced model for straightforward work.","visibility":"list","priority":7,"supported_in_api":true},{"slug":"gpt-5.6-luna","display_name":"GPT-5.6-Luna","description":"Older fast and efficient model.","visibility":"list","priority":8,"supported_in_api":true},{"slug":"gpt-daybreak-blue-latest","display_name":"Daybreak Blue","description":"Latest frontier agentic coding model for broad defensive cybersecurity work.","visibility":"hide","priority":10,"supported_in_api":true},{"slug":"gpt-daybreak-red-latest","display_name":"Daybreak Red","description":"Cyber-permissive variant of our latest frontier agentic coding model for advanced, authorized cybersecurity research.","visibility":"hide","priority":11,"supported_in_api":true},{"slug":"gpt-5.5","display_name":"GPT-5.5","description":"Legacy coding model.","visibility":"list","priority":12,"supported_in_api":true},{"slug":"gpt-5.4","display_name":"GPT-5.4","description":"Strong model for everyday coding.","visibility":"hide","priority":16,"supported_in_api":true},{"slug":"codex-auto-review","display_name":"Codex Auto Review","description":"Automatic approval review model for Codex.","visibility":"hide","priority":43,"supported_in_api":true}]} diff --git a/test/models-refresh-173.test.cjs b/test/models-refresh-173.test.cjs new file mode 100644 index 0000000000..9421327e16 --- /dev/null +++ b/test/models-refresh-173.test.cjs @@ -0,0 +1,343 @@ +'use strict'; + +/** + * REFRESH-MODELS (1.1.73; the Human's call via god c6e41f / df3983). ONE models file, + * userData/models.json, filled ONLY by the Settings button, read by every picker; the hard-coded + * lists are the floor. Plan and measurements: C:/Dunder/hive/agents/jim-mtujpe28/REFRESH-MODELS-PLAN.md. + * + * Adapters are tested on CAPTURED real output: `agy models` (agy 1.2.12) and `codex debug models` + * (codex 0.157.1, trimmed to the fields used: test/fixtures/codex-debug-models-0.157.1.json). + * opencode and the Anthropic Models API use their documented shapes (neither is available here). + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); +const { readSource: read, codeOnly } = require('./read-source.cjs'); +const { runScenario } = require('./electron-harness/run.cjs'); + +const P = loadTs('src/main/providerModels.ts'); +const C = loadTs('src/renderer/src/store/config.ts'); + +const AGY_STDOUT = [ + 'gemini-3.8-flash-high\tGemini 3.8 Flash (High)', 'gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)', 'gemini-3.8-flash-low\tGemini 3.8 Flash (Low)', + 'gemini-3.7-flash-high\tGemini 3.7 Flash (High)', 'gemini-3.7-flash-medium\tGemini 3.7 Flash (Medium)', 'gemini-3.7-flash-low\tGemini 3.7 Flash (Low)', + 'gemini-3.6-flash-high\tGemini 3.6 Flash (High)', 'gemini-3.6-flash-medium\tGemini 3.6 Flash (Medium)', 'gemini-3.6-flash-low\tGemini 3.6 Flash (Low)', + 'gemini-3.1-pro-high\tGemini 3.1 Pro (High)', 'gemini-3.1-pro-low\tGemini 3.1 Pro (Low)', + 'claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)', 'claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)', 'gpt-oss-120b-medium\tGPT-OSS 120B (Medium)', '' +].join('\n'); +// codex prints its catalog on stdout (one JSON line); the harness saw a stderr warning after it. +const CODEX_STDOUT = fs.readFileSync(path.join(__dirname, 'fixtures', 'codex-debug-models-0.157.1.json'), 'utf8'); + +// ── parsers ───────────────────────────────────────────────────────────────────────────── + +test('agy: 14 models from the real output; id = the LABEL (--model takes it), display "· Med"', () => { + const m = P.parseAgyModels(AGY_STDOUT); + assert.equal(m.length, 14); + assert.deepEqual(m[1], { id: 'Gemini 3.8 Flash (Medium)', label: 'Gemini 3.8 Flash · Med' }); + assert.deepEqual(m[12], { id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6' }); + assert.equal(P.parseAgyModels('Fetching available models...\nError: offline'), null); +}); + +test('codex: only visibility "list", in priority order, from the real 0.157.1 catalog', () => { + const m = P.parseCodexModels(CODEX_STDOUT); + assert.deepEqual(m.map((x) => x.id), ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5']); + assert.deepEqual(m[0], { id: 'gpt-6-astra', label: 'GPT-6 Astra' }); + assert.deepEqual(m[6], { id: 'gpt-5.5', label: 'GPT-5.5' }); + assert.equal(P.parseCodexModels('not json'), null); +}); + +test('opencode (provider/model lines) and the Anthropic Models API (data[].id / display_name)', () => { + assert.deepEqual(P.parseOpencodeModels('anthropic/claude-sonnet-4-5\nopenai/gpt-5\nnoise line\n'), [{ id: 'anthropic/claude-sonnet-4-5', label: 'anthropic/claude-sonnet-4-5' }, { id: 'openai/gpt-5', label: 'openai/gpt-5' }]); + assert.deepEqual(P.parseAnthropicModels({ data: [{ type: 'model', id: 'claude-opus-5-5', display_name: 'Claude Opus 5.5' }, { id: 'bad"id' }] }), [{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5' }]); + assert.equal(P.parseAnthropicModels({ error: {} }), null); +}); + +test('ids that could not be a safe --model argument never pass (quotes, cmd/shell metacharacters)', () => { + assert.deepEqual(P.cleanModels([{ id: 'ok-1', label: 'OK' }, { id: 'a"b', label: 'x' }, { id: 'a&b', label: 'x' }, { id: 'a;rm', label: 'x' }, { id: '$(x)', label: 'x' }, { id: 'ok-1', label: 'dup' }, { id: 'claude-opus-5-5[1m]', label: '1M' }]).map((m) => m.id), + ['ok-1', 'claude-opus-5-5[1m]']); +}); + +// ── the file ──────────────────────────────────────────────────────────────────────────── + +test('the file is re-validated on read: a hand edit cannot inject an id; bad entries are dropped', () => { + const f = P.validModelsFile({ version: 1, refreshedAt: 5, providers: { + antigravity: { status: 'ok', models: [{ id: 'Gemini X (High)', label: 'Gemini X · High' }, { id: 'Gemini "evil"', label: 'x' }] }, + codex: { status: 'ok', models: [{ id: 'a|b', label: 'x' }] }, + claude: { status: 'weird' }, + 'BAD ID': { status: 'ok', models: [{ id: 'x', label: 'x' }] } + } }); + assert.deepEqual(Object.keys(f.providers), ['antigravity'], '"ok" with nothing usable is not ok; unknown status and bad ids dropped'); + assert.equal(f.providers.antigravity.models.length, 1); + assert.equal(P.validModelsFile({ version: 2, refreshedAt: 1, providers: {} }), null); +}); + +test('the store re-validates the file it READS (a hand-edited models.json never reaches a picker unchecked)', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const p = path.join(dir, 'models.json'); + fs.writeFileSync(p, JSON.stringify({ version: 1, refreshedAt: 1, providers: { codex: { status: 'ok', models: [{ id: 'gpt-x" && calc', label: 'evil' }, { id: 'gpt-5.5', label: 'GPT-5.5' }] } } })); + const f = new P.ProviderModelStore({ path: p, now: () => 1 }).read(); + assert.deepEqual(f.providers.codex.models, [{ id: 'gpt-5.5', label: 'GPT-5.5' }]); +}); + +// ── adapters (fake exec: no real CLI runs here) ───────────────────────────────────────── + +function fakeExec(answers) { + const calls = []; + const exec = (file, args, opts, cb) => { + calls.push({ file, args, opts }); + const a = answers[file] ? answers[file](args, opts) : { err: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) }; + setImmediate(() => (a.err ? cb(a.err, '') : cb(null, a.stdout))); + }; + return { exec, calls }; +} +const winEnv = { LOCALAPPDATA: 'C:\\L', APPDATA: 'C:\\R', ComSpec: 'C:\\W\\cmd.exe' }; + +test('cli adapter: resolved ASYNC via where, run hidden + time-boxed; codex gets a large buffer', async () => { + const f = fakeExec({ where: (a) => ({ stdout: a[0] === 'codex' ? 'C:\\n\\codex\r\nC:\\n\\codex.cmd\r\n' : '' }), 'C:\\W\\cmd.exe': () => ({ stdout: CODEX_STDOUT }) }); + const d = { platform: 'win32', env: winEnv, exec: f.exec, exists: (p) => p === 'C:\\n\\codex.cmd' || p === 'C:\\n\\codex' }; + const r = await P.cliAdapter(d, 'codex', ['debug', 'models'], P.parseCodexModels, 4 * 1024 * 1024)(); + assert.equal(r.status, 'ok'); assert.equal(r.models.length, 7); + const run = f.calls.at(-1); + assert.equal(run.file, 'C:\\W\\cmd.exe', 'the .cmd shim is preferred over the extensionless one and runs through cmd.exe'); + assert.deepEqual(run.args, ['/d', '/s', '/c', '""C:\\n\\codex.cmd" debug models"']); + assert.equal(run.opts.windowsVerbatimArguments, true); + assert.equal(run.opts.windowsHide, true); assert.equal(run.opts.maxBuffer, 4 * 1024 * 1024); + assert.equal(run.opts.timeout, 0, 'on Windows the run is timed by the caller (so the TREE can be killed), not by execFile'); +}); + +test('cli adapter: not installed / failed (timeout, exit N) / unparsable, each with its reason', async () => { + const none = fakeExec({ where: () => ({ err: Object.assign(new Error('x'), { code: 1 }) }) }); + assert.deepEqual(await P.cliAdapter({ platform: 'win32', env: winEnv, exec: none.exec, exists: () => false }, 'agy', ['models'], P.parseAgyModels)(), { status: 'not-installed', reason: 'agy not found', source: 'agy models' }); + for (const [answer, reason] of [[{ err: Object.assign(new Error('t'), { killed: true }) }, 'timeout'], [{ err: Object.assign(new Error('e'), { code: 3 }) }, 'exit 3'], [{ stdout: 'garbage' }, 'unparsable output']]) { + const f = fakeExec({ where: () => ({ stdout: 'C:\\L\\agy\\bin\\agy.exe' }), 'C:\\L\\agy\\bin\\agy.exe': () => answer }); + const r = await P.cliAdapter({ platform: 'win32', env: winEnv, exec: f.exec, exists: () => true }, 'agy', ['models'], P.parseAgyModels)(); + assert.deepEqual([r.status, r.reason], ['failed', reason]); + } + const bad = fakeExec({ where: () => ({ stdout: 'C:\\a&b\\agy.cmd' }) }); + const r = await P.cliAdapter({ platform: 'win32', env: winEnv, exec: bad.exec, exists: () => true }, 'agy', ['models'], P.parseAgyModels)(); + assert.deepEqual([r.status, r.reason], ['failed', 'unsafe-path']); + assert.equal(bad.calls.filter((c) => c.file === 'C:\\W\\cmd.exe').length, 0, 'a metacharacter path never reaches cmd.exe'); +}); + +test('claude: the Models API ONLY with a stored Anthropic key; without one nothing is requested', async () => { + let fetched = 0; let headers = null; + const fetchJson = async (url, h) => { fetched++; headers = h; return { status: 200, body: { data: [{ id: 'claude-opus-5-5', display_name: 'Claude Opus 5.5' }] } }; }; + assert.deepEqual(await P.anthropicAdapter(() => undefined, fetchJson)(), { status: 'unsupported', reason: 'no list command (no Anthropic API key stored; built-in list kept)' }); + assert.equal(fetched, 0); + const ok = await P.anthropicAdapter(() => 'sk-ant-test', fetchJson)(); + assert.deepEqual([ok.status, ok.models], ['ok', [{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5' }]]); + assert.deepEqual(headers, { 'x-api-key': 'sk-ant-test', 'anthropic-version': '2023-06-01' }); + const denied = await P.anthropicAdapter(() => 'k', async () => ({ status: 401, body: {} }))(); + assert.deepEqual([denied.status, denied.reason], ['failed', 'HTTP 401']); +}); + +test('defaultAdapters covers every provider except custom; codex/agy/opencode list, claude via the API, the rest report', () => { + const ids = Object.keys(P.defaultAdapters({ platform: 'win32', env: {}, exec: () => {}, exists: () => false }, () => undefined, async () => ({ status: 0, body: null }))); + assert.deepEqual(ids.sort(), ['antigravity', 'claude', 'codex', 'copilot', 'crush', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi', 'qwen']); +}); + +// ── the store: merge, keep-last, one at a time, atomic write, log ─────────────────────── + +test('store: merges results, a failed provider keeps its last good list, added/removed per provider, one row logged', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const logs = []; let now = 1000; + const store = new P.ProviderModelStore({ path: path.join(dir, 'models.json'), now: () => now, log: (r) => logs.push(r) }); + assert.equal(store.read(), null, 'no file yet: pickers use the floor'); + const agy1 = [{ id: 'Gemini 3.5 Flash (Medium)', label: 'old' }, { id: 'Gemini 3.8 Flash (High)', label: 'new' }]; + await store.refresh({ antigravity: async () => ({ status: 'ok', models: agy1, source: 'agy models' }), codex: async () => ({ status: 'ok', models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }], source: 'codex debug models' }), opencode: async () => ({ status: 'not-installed', reason: 'opencode not found' }) }); + now = 2000; + const { file, rows } = await store.refresh({ + antigravity: async () => ({ status: 'ok', models: [agy1[1], { id: 'Gemini 3.9 Flash (High)', label: '3.9' }], source: 'agy models' }), + codex: async () => ({ status: 'failed', reason: 'timeout', source: 'codex debug models' }), + opencode: async () => ({ status: 'not-installed', reason: 'opencode not found' }) + }); + const byId = Object.fromEntries(rows.map((r) => [r.provider, r])); + assert.deepEqual([byId.antigravity.added, byId.antigravity.removed], [['Gemini 3.9 Flash (High)'], ['Gemini 3.5 Flash (Medium)']]); + assert.deepEqual([byId.codex.status, byId.codex.keptLast, byId.codex.count, file.providers.codex.models], ['failed', true, 1, [{ id: 'gpt-5.5', label: 'GPT-5.5' }]]); + assert.equal(file.providers.opencode.status, 'not-installed'); + assert.equal(file.refreshedAt, 2000); + assert.deepEqual(logs.at(-1).providers.antigravity, { status: 'ok', count: 2, added: 1, removed: 1 }); + const again = new P.ProviderModelStore({ path: path.join(dir, 'models.json'), now: () => now }); + assert.deepEqual(again.read(), file, 'the file on disk is what the next start reads'); + assert.ok(!fs.existsSync(path.join(dir, 'models.json.tmp')), 'written atomically (temp + rename)'); +}); + +test('store: one refresh at a time (a second click joins the running one)', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + let runs = 0; let release; + const store = new P.ProviderModelStore({ path: path.join(dir, 'm.json'), now: () => 1 }); + const adapters = { antigravity: () => { runs++; return new Promise((r) => { release = () => r({ status: 'ok', models: [{ id: 'A (High)', label: 'A' }], source: 's' }); }); } }; + const a = store.refresh(adapters); const b = store.refresh(adapters); + assert.equal(store.isRefreshing(), true); + release(); + assert.equal(await a, await b); assert.equal(runs, 1); assert.equal(store.isRefreshing(), false); +}); + +// ── the pickers ───────────────────────────────────────────────────────────────────────── + +test('pickers: the floor until a refresh; then each provider\'s own list; a failed refresh keeps its last list; saved ids no longer offered are marked', () => { + assert.equal(C.currentModelCatalog(), null); + const agyFloor = C.modelsForProvider('antigravity').map((m) => m.id).filter(Boolean); + assert.ok(agyFloor.includes('Gemini 3.8 Flash (High)'), 'the AGY floor is today\'s list'); + assert.deepEqual(C.modelsForProvider('codex').map((m) => m.id), [undefined, 'gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-sol', 'gpt-6-terra', 'gpt-5.6-terra', 'gpt-6-luna', 'gpt-5.6-luna'], 'the Codex floor is today\'s list'); + assert.equal(C.unknownModelSuffix('antigravity', '(current)'), '(current)'); + C.setModelCatalog({ version: 1, refreshedAt: 1, providers: { + antigravity: { status: 'ok', models: [{ id: 'Gemini 3.9 Flash (High)', label: 'Gemini 3.9 Flash · High' }] }, + codex: { status: 'failed', reason: 'timeout', models: [{ id: 'gpt-6-astra', label: 'GPT-6 Astra' }] }, + claude: { status: 'ok', models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }] }, + gemini: { status: 'unsupported', reason: 'no list command' } + } }); + try { + assert.deepEqual(C.modelsForProvider('antigravity').map((m) => m.id), [undefined, 'Gemini 3.9 Flash (High)']); + assert.deepEqual(C.modelsForProvider('codex').map((m) => m.id), [undefined, 'gpt-6-astra'], 'failed + kept list: the last good list'); + assert.deepEqual(C.modelsForProvider('claude').map((m) => m.id), [undefined, 'claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'], 'Claude keeps the curated 1M variant of a listed model'); + assert.deepEqual(C.modelsForProvider('gemini').map((m) => m.id), [undefined, 'auto', 'gemini-4-argon', 'pro', 'flash', 'flash-lite'], 'no list -> the floor'); + assert.equal(C.unknownModelSuffix('antigravity', '(current)'), '· not in the refreshed list'); + assert.equal(C.unknownModelSuffix('gemini', '(current)'), '(current)'); + } finally { + C.setModelCatalog(null); + } +}); + +// ── wiring: button-only, async-only ───────────────────────────────────────────────────── + +test('WIRING: nothing is looked up at startup or picker open; only models:refresh refreshes; async only', () => { + const idx = codeOnly(read('src/main/index.ts')); + assert.equal((idx.match(/providerModels\.refresh\(/g) || []).length, 1, 'refresh() is called in exactly one place'); + assert.match(idx, /ipcMain\.handle\('models:refresh', async \(\) => \{[\s\S]{0,1400}providerModels\.refresh\(defaultAdapters\(/); + assert.match(idx, /ipcMain\.handle\('models:catalog', \(\) => providerModels\.read\(\)\);/, 'the catalog IPC is a file read'); + assert.doesNotMatch(idx, /models:agy/, 'the automatic AGY lookup is gone'); + assert.match(idx, /integrations\.getSecret\(providerKeyRef\('anthropic'\)\)/, 'Claude: only the stored BYOK key'); + assert.doesNotMatch(codeOnly(read('src/main/providerModels.ts')), /spawnSync|execSync|execFileSync|readFileSync\([^)]*credentials/, 'no sync child process; no subscription credentials'); + const pre = read('src/preload/index.ts'); + assert.match(pre, /modelCatalog: \(\): Promise => ipcRenderer\.invoke\('models:catalog'\)/); + assert.match(pre, /refreshModels: \(\)[^\n]*ipcRenderer\.invoke\('models:refresh'\)/); + assert.match(read('src/renderer/src/App.tsx'), /useEffect\(\(\) => \{ void loadModelCatalog\(\); \}, \[\]\);/); + assert.match(read('src/renderer/src/components/SettingsModal.tsx'), //); + for (const f of ['AddAgentModal', 'EditAgentModal']) assert.match(read(`src/renderer/src/components/${f}.tsx`), /unknownModelSuffix\(provider, /); +}); + +// ── the button, rendered (hidden harness) ─────────────────────────────────────────────── + +test('RENDERED: the Settings button - floor before, one refresh per click burst, per-provider rows, pickers updated', { timeout: 150_000 }, async () => { + const r = await runScenario(path.join(__dirname, 'electron-harness', 'scenarios', 'models-refresh-button.tsx'), { timeoutMs: 120_000 }); + assert.equal(r.ok, true, r.error); + assert.equal(r.before.refreshCalls, 0, 'mounting looks nothing up'); + assert.ok(r.before.picker.includes('Gemini 3.8 Flash · High'), `floor: ${r.before.picker}`); + assert.match(r.before.panel, /Not refreshed yet/); + assert.deepEqual(r.during, { label: 'Refreshing…', disabled: true }); + assert.equal(r.calls.refreshModels, 1, 'a second click while busy does nothing'); + assert.deepEqual(r.after.rows, [ + ['antigravity', 'listed', '2 models · +1 new · −1 removed: Gemini 3.5 Flash (Medium)'], + ['claude', 'no list', 'no list command (no Anthropic API key stored; built-in list kept)'], + ['opencode', 'not installed', 'opencode not found'], + ['codex', 'failed', 'timeout · kept the last list (1)'] + ]); + assert.deepEqual(r.after.picker, ['CLI default', 'Gemini 3.9 Flash · High', 'Gemini 3.8 Flash · High'], 'the picker follows the pushed file'); + assert.equal(r.lastRefreshed, true); + assert.deepEqual([r.after.button, r.after.disabled], ['Refresh models', false]); +}); + +// ── MODELS-173-AUDIT (Dwight): the time box ends the whole Windows process TREE ───────── + +test('win32 timeout: the still-live child TREE is killed (taskkill /T /F on its pid), then "timeout"', async () => { + const calls = []; + const exec = (file, args, opts, cb) => { + calls.push({ file, args, opts }); + if (file === 'taskkill') { setImmediate(() => cb(null, 'SUCCESS')); return { pid: 1 }; } + return { pid: 4242 }; // cmd.exe that never finishes (its codex child hangs) + }; + const r = await P.runCli({ platform: 'win32', env: winEnv, exec, exists: () => true }, 'C:\\n\\codex.cmd', ['debug', 'models'], 1024, 40); + assert.deepEqual(r, { reason: 'timeout' }); + const kill = calls.find((c) => c.file === 'taskkill'); + assert.deepEqual(kill.args, ['/PID', '4242', '/T', '/F']); + assert.equal(calls[0].opts.timeout, 0); +}); + +test('win32 timeout: a failing or hanging taskkill, and a late exit, are tolerated (still one "timeout")', async () => { + let late = null; + const exec = (file, args, opts, cb) => { + if (file === 'taskkill') { setImmediate(() => cb(Object.assign(new Error('not found'), { code: 128 }), '')); return {}; } + late = cb; return { pid: 7 }; + }; + const r = await P.runCli({ platform: 'win32', env: winEnv, exec, exists: () => true }, 'C:\\a\\agy.exe', ['models'], 1024, 30); + assert.deepEqual(r, { reason: 'timeout' }); + late(null, 'gemini-x\tGemini X (High)'); // the process exits after all: ignored + const quick = await P.runCli({ platform: 'win32', env: winEnv, exec: (f, a, o, cb) => { setImmediate(() => cb(null, 'ok')); return { pid: 9 }; }, exists: () => true }, 'C:\\a\\agy.exe', ['models'], 1024, 5000); + assert.deepEqual(quick, { stdout: 'ok' }, 'a run that finishes in time is untouched'); +}); + +test('POSIX keeps execFile\'s own timeout', async () => { + let seen = null; + await P.runCli({ platform: 'linux', env: {}, exec: (f, a, o, cb) => { seen = o; setImmediate(() => cb(null, 'x')); }, exists: () => true }, '/usr/bin/agy', ['models'], 1024, 1234); + assert.equal(seen.timeout, 1234); +}); + +test('REAL (Windows): a .cmd shim whose node grandchild hangs is killed WITH its tree at the time box', { skip: process.platform !== 'win32' }, async (t) => { + const cp = require('node:child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'treekill-')); + t.after(() => { try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best effort */ } }); + const pidFile = path.join(dir, 'grandchild.pid'); + const shim = path.join(dir, 'hang.cmd'); + fs.writeFileSync(shim, `@"${process.execPath}" -e "require('fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)" "${pidFile}"\r\n`); + const d = { platform: 'win32', env: process.env, exists: fs.existsSync, exec: (file, args, opts, cb) => cp.execFile(file, args, opts, (err, stdout) => cb(err, String(stdout ?? ''))) }; + const t0 = Date.now(); + const r = await P.runCli(d, shim, ['models'], 1024, 2500); + assert.deepEqual(r, { reason: 'timeout' }); + assert.ok(Date.now() - t0 < 15_000, 'bounded'); + const pid = Number(fs.readFileSync(pidFile, 'utf8')); + assert.ok(pid > 0, 'the grandchild ran'); + let alive = true; + for (let i = 0; i < 40 && alive; i++) { + try { process.kill(pid, 0); await new Promise((res) => setTimeout(res, 100)); } catch { alive = false; } + } + if (alive) { try { process.kill(pid); } catch { /* */ } } + assert.equal(alive, false, 'the node GRANDCHILD of the .cmd shim is gone (no orphaned tree)'); +}); + +// ── The Human's rule (god 34c5a5): NO model lookup at launch or on picker open - button only ──── + +test('NO LOOKUP AT LAUNCH (main): building the adapters, creating the store and reading the file run ZERO child processes', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + let execs = 0; let fetches = 0; + const cli = { platform: 'win32', env: winEnv, exists: () => true, exec: () => { execs++; return { pid: 1 }; } }; + const adapters = P.defaultAdapters(cli, () => 'sk-ant-key', async () => { fetches++; return { status: 200, body: { data: [] } }; }); + fs.writeFileSync(path.join(dir, 'models.json'), JSON.stringify({ version: 1, refreshedAt: 1, providers: { codex: { status: 'ok', models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }] } } })); + const store = new P.ProviderModelStore({ path: path.join(dir, 'models.json'), now: () => 1 }); + for (let i = 0; i < 5; i++) store.read(); + assert.ok(Object.keys(adapters).length >= 12); + assert.deepEqual([execs, fetches, store.isRefreshing()], [0, 0, false], 'nothing is looked up until refresh() is called'); +}); + +test('NO LOOKUP AT LAUNCH (index.ts): the adapters are built and run ONLY inside the models:refresh handler', () => { + const idx = codeOnly(read('src/main/index.ts')); + const handler = idx.slice(idx.indexOf("ipcMain.handle('models:refresh'"), idx.indexOf("ipcMain.handle('tools:status'")); + assert.ok(handler.length > 0); + for (const call of ['defaultAdapters(', 'providerModels.refresh(']) { + assert.equal(idx.split(call).length - 1, 1, `${call} appears exactly once`); + assert.ok(handler.includes(call), `${call} is inside the button handler`); + } + assert.equal((idx.match(/execFile\(/g) || []).length, 1, 'the only execFile call in index.ts is the adapters\' exec (inside the handler)'); + assert.ok(handler.includes('execFile(')); +}); + +test('NO LOOKUP AT LAUNCH (renderer): loading the catalog and opening every picker never asks for a refresh', async () => { + const saved = global.window; + let refreshes = 0; let reads = 0; + global.window = { cth: { modelCatalog: async () => { reads++; return null; }, refreshModels: async () => { refreshes++; return { rows: [] }; }, onModelCatalogChanged: () => () => {} } }; + try { + await C.loadModelCatalog(); + for (const provider of ['claude', 'codex', 'grok', 'kimi', 'gemini', 'antigravity', 'qwen', 'opencode', 'crush', 'pi', 'copilot', 'cursor', 'custom']) C.modelsForProvider(provider); + assert.equal(refreshes, 0, 'launch + every picker: zero refreshes'); + assert.ok(reads <= 1, 'at most one file read'); + } finally { + global.window = saved; + } +}); diff --git a/test/read-source.cjs b/test/read-source.cjs new file mode 100644 index 0000000000..7839e729b5 --- /dev/null +++ b/test/read-source.cjs @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Read a SOURCE file for a test that inspects its text — LINE-ENDING INDEPENDENT. + * + * WHY THIS EXISTS. This repo has `core.autocrlf=true` and no `.gitattributes`, so the + * same committed file is LF in the working tree it was authored in and CRLF in a fresh + * checkout. A test that searches source for a literal containing `\n` therefore passes in + * its author's tree and fails — or worse, passes for a different reason — everywhere else. + * Dwight's validation of L0-FUSION stage 5.1 caught exactly that: `commitSectionSource` + * looked for `'\n}\n'`, found nothing in a CRLF checkout, and failed before it had + * inspected a single prohibition. Green only in the tree it was written in. + * + * So: every source-reading test in the L0-FUSION stage-5 files reads THROUGH HERE, and a + * registered test asserts that none of them calls `readFileSync` on its own. + */ +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); + +/** CRLF and lone CR both become LF. Idempotent. */ +function normaliseEol(text) { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/** Read a repo file (relative to the repo root, or absolute) with LF line endings. */ +function readSource(file) { + return normaliseEol(fs.readFileSync(path.isAbsolute(file) ? file : path.join(ROOT, file), 'utf8')); +} + +/** The same text as a CRLF checkout and as an LF checkout would hold it on disk. */ +function bothEolRenderings(text) { + const lf = normaliseEol(text); + return { lf, crlf: lf.replace(/\n/g, '\r\n') }; +} + +/** + * Source with its COMMENTS blanked out, for a check that something is ABSENT from code (the + * files that removed a thing are exactly the files whose comments explain the removal). + * + * PARSED, NOT PATTERN-MATCHED. The first version was two regexes, and a `/*` inside a + * STRING in src/main/index.ts made it swallow about two thousand lines of real code as one + * "comment" - an absence check that could not see the code it was checking. Here the + * TypeScript parser decides what a token is, and only the comment ranges between tokens + * are blanked (with spaces, so offsets and line numbers survive). + */ +function codeOnly(text, fileName = 'x.tsx') { + const ts = require('typescript'); + const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX); + const ranges = []; + const visit = (node) => { + const kids = node.getChildren(sf); + if (!kids.length || node.kind === ts.SyntaxKind.JsxText) { + for (const r of ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []) ranges.push(r); + for (const r of ts.getTrailingCommentRanges(text, node.getEnd()) ?? []) ranges.push(r); + return; + } + kids.forEach(visit); + }; + visit(sf); + let out = text; + for (const r of ranges) out = out.slice(0, r.pos) + out.slice(r.pos, r.end).replace(/[^\n]/g, ' ') + out.slice(r.end); + return out; +} + +module.exports = { ROOT, normaliseEol, readSource, bothEolRenderings, codeOnly }; From 50a524e1544d407c0aac6a1488ecfa9b7c8b00f7 Mon Sep 17 00:00:00 2001 From: Jeff Tindell Date: Sat, 17 Oct 2026 22:04:45 -0400 Subject: [PATCH 77/78] fix(models): retire duplicate models:catalog IPC handler and remove startup remote lookup --- src/main/index.ts | 15 --------------- src/renderer/src/store/config.ts | 6 ------ 2 files changed, 21 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 1d719a2d07..160833f712 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -89,7 +89,6 @@ import { detectNodeVersion, nodeIsUsable, resolveNodeInstaller } from './nodeIns import { toolCatalog, type ToolStatus } from '../shared/toolCatalog'; import { listLocalSkills, loadCatalog, installSkill, uninstallSkill, type LocalSkill } from './skills'; import { loadHero } from './hero'; -import { loadModelCatalog } from './modelCatalog'; import { CODEX_REMOTE_SOCKET_RELATIVE, codexRemoteAliasPath, @@ -3770,14 +3769,6 @@ ipcMain.handle('hive:patchAgentRole', (_evt, id: unknown, role: unknown) => { ipcMain.handle('hero:payload', async (_evt, force: unknown) => loadHero(join(app.getPath('userData'), 'hero.json'), { force: force === true })); -// ─── IPC: model catalog (remote data, cached) ─────────────────────────────── -/** The agent model presets, fetched from docs/model-catalog.json on main so a - * new model reaches installed copies without a release. Validated in - * shared/modelCatalogPayload; a null catalog means "keep the baked one". */ -const MODEL_CATALOG_CACHE = () => join(app.getPath('userData'), 'model-catalog.json'); -ipcMain.handle('models:catalog', async (_evt, force: unknown) => - loadModelCatalog(MODEL_CATALOG_CACHE(), { force: force === true })); - // ─── IPC: skills (installed locally, and the browsable catalog) ───────────── /** Skills the CLIs on this machine can already use. Scans the registered repos * plus the agent's own cwd, so a project-scoped skill shows up where it applies. */ @@ -5644,12 +5635,6 @@ app.whenReady().then(() => { enabled: readConfig().telemetryEnabled !== false }); - // Warm the model catalog cache before any picker opens. The renderer reads - // the same cache over IPC on load; doing the network hop here means the file - // is already fresh on disk by the time a modal is opened, and a failure is - // silent by construction (the baked catalog is the floor). - void loadModelCatalog(MODEL_CATALOG_CACHE()).catch(() => { /* never fatal */ }); - // A cold-start deep link (Windows/Linux) rides in on OUR argv. const startupHireLink = process.argv.find((a) => a.startsWith('munderdifflin://')); if (startupHireLink) void handleHireLink(startupHireLink); diff --git a/src/renderer/src/store/config.ts b/src/renderer/src/store/config.ts index 41a4f26807..aa1cbcdb61 100644 --- a/src/renderer/src/store/config.ts +++ b/src/renderer/src/store/config.ts @@ -294,12 +294,6 @@ export async function refreshModelCatalog(force = false): Promise { } } -// Fire once on load. The pickers all call modelsForProvider() during render, so -// the usual case — main pre-warmed the cache at startup, this resolves in a few -// ms, no modal is open yet — needs no subscription at all. A catalog that lands -// while a picker is already open reaches it on that picker's next render. -if (typeof window !== 'undefined') void refreshModelCatalog(); - declare const __APP_VERSION__: string | undefined; /** The version of the running build. electron-vite replaces `__APP_VERSION__` From 9dd540d584460019fc5789bee5e1a04f38954811 Mon Sep 17 00:00:00 2001 From: Jeff Tindell Date: Sun, 18 Oct 2026 15:01:36 -0400 Subject: [PATCH 78/78] fix: update compatible vulnerable dependencies --- package-lock.json | 114 ++++++++++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index c759a64e8a..7678643990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -635,9 +635,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -974,9 +974,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1852,13 +1852,13 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "optional": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -3453,9 +3453,9 @@ "peer": true }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true, @@ -3871,9 +3871,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4667,9 +4667,9 @@ "license": "MIT" }, "node_modules/config-file-ts/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5148,9 +5148,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6197,9 +6197,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -6247,9 +6247,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6594,9 +6594,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6824,9 +6824,9 @@ } }, "node_modules/hono": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", - "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "optional": true, "engines": { @@ -6886,9 +6886,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -7132,9 +7132,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "devOptional": true, "license": "MIT", "engines": { @@ -10010,9 +10010,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "optional": true, "dependencies": { @@ -10021,6 +10021,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -10330,9 +10334,9 @@ "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "peer": true, @@ -10546,9 +10550,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -11157,9 +11161,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12557,9 +12561,9 @@ "peer": true }, "node_modules/zip-stream/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true,