Files
shiro-neko/test/prompt-input.test.tsx
T
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

280 lines
8.7 KiB
TypeScript

import { expect, test } from 'bun:test';
import { render } from 'ink-testing-library';
import React, { useState } from 'react';
import { PromptInput, type PromptInputProps } from '../src/ui/PromptInput';
/**
* `PromptInput` is exercised through `App` in `input.test.tsx` (history recall,
* arrow and word motion, ctrl-u, ctrl-d, paste). This file renders it directly
* for the behaviours that reaching it through the whole app made awkward: the
* emacs kill keys, the mask, a blurred input, and the `onKey` escape hatch that
* `App` uses to hand up/down/tab/esc to its own menus.
*/
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Keys that are awkward to read as escape sequences at the call site. */
const KEYS = {
up: '\u001B[A',
down: '\u001B[B',
left: '\u001B[D',
ctrlA: '\u0001',
ctrlD: '\u0004',
ctrlE: '\u0005',
ctrlK: '\u000B',
ctrlU: '\u0015',
ctrlW: '\u0017',
backspace: '\u007F',
} as const;
/**
* The input is controlled, so it needs an owner to feed `value` back. This wraps
* it with the state a caller would hold and records every value it reports.
*/
type HarnessProps = Omit<Partial<PromptInputProps>, 'value' | 'onChange' | 'onSubmit'> & {
initial?: string;
onChange?: (value: string, cursor: number) => void;
onSubmit?: (value: string) => void;
};
function Harness({ initial = '', onChange, onSubmit, ...rest }: HarnessProps) {
const [value, setValue] = useState(initial);
return (
<PromptInput
{...rest}
value={value}
onSubmit={onSubmit ?? (() => {})}
onChange={(next, cursor) => {
onChange?.(next, cursor);
setValue(next);
}}
/>
);
}
function mount(props: HarnessProps = {}) {
const changes: { value: string; cursor: number }[] = [];
const submitted: string[] = [];
const app = render(
<Harness
{...props}
onChange={(value, cursor) => changes.push({ value, cursor })}
onSubmit={(v) => submitted.push(v)}
/>,
);
return { app, changes, submitted };
}
async function press(app: ReturnType<typeof render>, s: string, ms = 80) {
app.stdin.write(s);
await wait(ms);
}
/** The visible text with every SGR sequence removed, i.e. what the user actually reads. */
const plain = (frame: string) => frame.replace(/\u001B\[[0-9;]*m/g, '');
test('the rendered line carries no hand-written SGR escapes', async () => {
// The cursor must be Ink's `inverse` prop, not `\u001B[7m` pasted into the string.
// Ink measures string content as printable columns, so an embedded escape is
// counted as text and shifts the line — the stray `t` before `ype` in the
// placeholder, and a corrupted cell wherever the line wraps.
const { app } = mount({ initial: 'abc', placeholder: `type to queue${String.fromCharCode(0x2026)}` });
await wait(40);
const frame = app.lastFrame() ?? '';
expect(frame).not.toContain('\u001B[7m');
expect(frame).not.toContain('\u001B[27m');
expect(plain(frame)).toBe('abc');
app.unmount();
});
test('the placeholder renders as one unbroken run of text', async () => {
const { app } = mount({ placeholder: 'ask anything' });
await wait(40);
// No escape may sit between the first character and the rest.
expect(plain(app.lastFrame() ?? '')).toBe('ask anything');
app.unmount();
});
test('a focused input keeps the caret off the string', async () => {
const { app } = mount({ initial: 'abc' });
await wait(40);
// At the end of the line the caret is a blank cell, which Ink trims.
expect(plain(app.lastFrame() ?? '')).toBe('abc');
app.unmount();
});
test('a bare blurred input renders no caret at all', async () => {
const { app } = mount({ focus: false });
await wait(40);
const frame = app.lastFrame() ?? '';
expect(frame).not.toContain('\u001B[7m');
expect(frame.trim()).toBe('');
app.unmount();
});
test('the caret marks the first placeholder character while focused', async () => {
const { app } = mount({ placeholder: 'ask anything' });
await wait(40);
expect(plain(app.lastFrame() ?? '')).toBe('ask anything');
app.unmount();
const blurred = mount({ placeholder: 'ask anything', focus: false });
await wait(40);
expect(blurred.app.lastFrame()).toBe('ask anything');
blurred.app.unmount();
});
test('the cursor moves one character at a time with the arrows', async () => {
const { app, changes } = mount({ initial: 'abc' });
await wait(40);
expect(plain(app.lastFrame() ?? '')).toBe('abc');
await press(app, KEYS.left);
await press(app, KEYS.left);
await press(app, 'Z');
expect(app.lastFrame()).toBe('aZbc');
expect(changes.at(-1)?.cursor).toBe(2);
app.unmount();
});
test('a masked input hides the value and keeps its length', async () => {
const { app } = mount({ initial: 'abcd', mask: '*' });
await wait(40);
expect(app.lastFrame()).toBe('****');
app.unmount();
});
test('a mask stays masked as the value shortens', async () => {
const { app } = mount({ initial: 'abcd', mask: '*' });
await wait(40);
// The harness owns the value, so backspace shortens it to three stars.
await press(app, KEYS.backspace);
expect(app.lastFrame()).toBe('***');
app.unmount();
});
test('ctrl-a and ctrl-e move the cursor between the ends of the line', async () => {
const { app } = mount({ initial: 'inline' });
await wait(40);
// Both keys only move the caret, so the proof is where the next character lands.
await press(app, KEYS.ctrlA);
await press(app, 'X');
expect(app.lastFrame()).toBe('Xinline');
await press(app, KEYS.ctrlE);
await press(app, 'Y');
expect(app.lastFrame()).toBe('XinlineY');
app.unmount();
});
test('ctrl-k kills from the cursor to the end', async () => {
const { app } = mount({ initial: 'keep this' });
await wait(40);
await press(app, KEYS.ctrlA);
// Three characters in, then kill the tail.
for (let i = 0; i < 3; i++) await press(app, KEYS.left.replace('\u001B[D', '\u001B[C'), 30);
await press(app, KEYS.ctrlK);
expect(app.lastFrame()).toContain('kee');
expect(app.lastFrame()).not.toContain('keep this');
app.unmount();
});
test('ctrl-w deletes the word before the cursor and its padding', async () => {
const { app } = mount({ initial: 'one two three' });
await wait(40);
await press(app, KEYS.ctrlW);
expect(app.lastFrame()).toContain('one two');
expect(app.lastFrame()).not.toContain('three');
await press(app, KEYS.ctrlW);
expect(app.lastFrame()).toContain('one');
expect(app.lastFrame()).not.toContain('two');
app.unmount();
});
test('ctrl-w on a single word leaves an empty line', async () => {
const { app } = mount({ initial: 'lonely' });
await wait(40);
await press(app, KEYS.ctrlW);
expect(app.lastFrame()).not.toContain('lonely');
app.unmount();
});
test('onKey can swallow a key before the input sees it', async () => {
const seen: string[] = [];
const { app, submitted } = mount({
initial: 'draft',
onKey: (input, key) => {
if (key.upArrow) {
seen.push('up');
return true;
}
return false;
},
history: ['from history'],
});
await wait(40);
await press(app, KEYS.up);
expect(seen).toEqual(['up']);
expect(app.lastFrame()).toContain('draft');
expect(app.lastFrame()).not.toContain('from history');
// A key onKey declines still reaches the input.
await press(app, '!');
expect(app.lastFrame()).toContain('draft!');
expect(submitted).toEqual([]);
app.unmount();
});
test('initialCursor puts the caret mid-line, so typing lands there', async () => {
const { app } = mount({ initial: 'abcdef', initialCursor: 2 });
await wait(40);
expect(app.lastFrame()).toBe('abcdef');
await press(app, 'X');
expect(app.lastFrame()).toBe('abXcdef');
app.unmount();
});
test('an external value renders as given and reports no change', async () => {
const changes: { value: string; cursor: number }[] = [];
const app = render(
<PromptInput
value="set from outside"
onChange={(v, c) => changes.push({ value: v, cursor: c })}
onSubmit={() => {}}
/>,
);
await wait(40);
// A prop with a handler that does not feed the value back: the text renders
// exactly as passed and the input reports nothing until a key is pressed.
expect(app.lastFrame()).toBe('set from outside');
expect(changes).toEqual([]);
app.unmount();
});
test('submit reports the current value and resets the cursor', async () => {
const { app, submitted } = mount({ initial: 'send me' });
await wait(40);
await press(app, '\r', 120);
expect(submitted).toEqual(['send me']);
app.unmount();
});
test('typing inserts at the cursor rather than appending', async () => {
const { app } = mount({ initial: 'ac' });
await wait(40);
await press(app, KEYS.left);
await press(app, 'b');
expect(app.lastFrame()).toBe('abc');
app.unmount();
});