live: heartbeat sync 2026-10-21T17:49:41-07:00 (Refs #11)

This commit is contained in:
Frank committed 2026-10-21 17:49:41 -07:00
1 parent c0d840a8bb
commit 017404aa51
30 files changed
+568 -109

No files matched your search

@@ -0,0 +1,69 @@
---
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 <repo>/node_modules/@oh-my-pi <work>/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<void>((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<string, typeof style>, { 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<n> 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).