--- name: omp-plugin-modal-bats-harness description: "Drive a real omp-extension settings modal headlessly from bats (ui.custom stub, DV_KEYS key-replay) and ship the bundle with host pi packages external." --- # Headless modal testing + host-external bundling for omp plugins Friction-earned 2026-10-21 on `dank/dankvoice` PR #31 (select menu → vendored pi-common settings modal). Three failed driver iterations and one 43 MB bundle before green. ## The build contract (do this first) A modal imports runtime values (`SelectList`, `matchesKey`, `getSelectListTheme`) from `@oh-my-pi/pi-tui` / `@oh-my-pi/pi-coding-agent`. Bundling the root barrels = 43 MB and trips `--target node` on bun builtins. The plugin host IS bun and hoists every pi package into `~/.omp/plugins/node_modules/@oh-my-pi/` — the same ground pi-common stands on. So: ``` bun build src/index.ts --outdir dist --target bun --format esm \ --external @oh-my-pi/pi-coding-agent --external @oh-my-pi/pi-tui \ --external omp-legacy-pi-modules ``` - `omp-legacy-pi-modules` is a harness-internal virtual module behind a lazy dynamic import in the pi-coding-agent barrel; externalizing is safe, omitting it fails the build. - Declare the externals in `peerDependencies`. - The packed-artifact load test must reproduce the hoisted host layout: extract the tarball, then `ln -s /node_modules/@oh-my-pi /node_modules/@oh-my-pi` before importing the entry — a bare temp dir has nothing to resolve against and the test fails for the wrong reason. ## The driver stub for `ctx.ui.custom` The modal mounts through `ctx.ui.custom(factory, opts)`; factory signature is `(tui, theme, keybindings, done)`. The bats driver stub: ```ts custom: (factory: any) => { customCall += 1; let settleDone: () => void = () => {}; const closed = new Promise((r) => { settleDone = r; }); // theme: Proxy answering EVERY style method; pass through the LAST arg — // style calls are (colorName, text); returning args[0] prints color names, // and a fixed {bg,fg,inverse} trio crashes the frame's theme.bold. const style = (...a: unknown[]) => String(a[a.length - 1] ?? ""); const theme = new Proxy({} as Record, { get: () => style }); const component = factory( { requestRender: () => {}, terminal: { rows: 60, cols: 100 } }, // rows feeds list pagination; omit → NaN, nothing scrolls theme, {}, () => settleDone(), ); const census = () => { for (const l of component.render?.(100) ?? []) console.log(`ROW ${l}`); }; census(); // keys from DV_KEYS / DV_KEYS per call: up|down|enter|tab|esc|ctrl+u named, // name*N repeat, anything else typed. (async () => { /* for each token: beats below; await ~140ms after enter (async roster fetch), ~20ms otherwise; final ~150ms; settleDone(); */ })(); return closed; }, ``` ## Key-replay discipline (each cost a red iteration) - **Never zero-interval.** A burst of `\x1b[B` bytes in one tick coalesces in SelectList — `down*3` lands one step. Beat ~25 ms between repeats. - **Type one character at a time.** A whole sentence written to the inline editor in one `handleInput` is swallowed (paste-shaped); iterate `[...text]` with ~10 ms beats. - **Sleep after every `enter`**, not just fixed small delays: Enter opens submenus whose list contents arrive from an async fetch. - Row math must be pinned: row order in `settingsFields` is the UI contract; tests reach row N with `down*N`. ## Vendored-modal semantics that bite - Enum fields with `options.length <= cycleThreshold` (default) CYCLE inline on Enter instead of opening the picker — a curated list must set `cycleThreshold: 0` or an "open picker, down, enter" choreography silently cycles and the stray Enter activates the next row. - Submenus render inside the body component's `render()` output — one census sees both. - SelectList initial highlight = current value (`items.findIndex(value)`); choreography counts from there. ## Test layering that held Component-level pins (field census, commits, audition) against fake deps with bun test; behavior bats drives the REAL bundle through the stub for wiring laws (persistence, byDir, notify strings). Don't duplicate choreographies across layers. Related managed skills: `gitea-pr-api-cycle` (landing), `plugin-git-install-committed-artifact` (install validation).