Files
command-center/agent/managed-skills/omp-plugin-modal-bats-harness/SKILL.md
T

4.3 KiB

name, description
name description
omp-plugin-modal-bats-harness 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:

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).