Files
shiro-neko/test/ui-panels.test.tsx
T
Muhammad Zakir Ramadhan 5b8503fcd9 Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK.

Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff.

Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming.

Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model.

Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents.

State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies.

Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems.

404 tests, typecheck clean.
2026-09-02 17:30:18 +07:00

248 lines
8.0 KiB
TypeScript

import { expect, test } from 'bun:test';
import { render } from 'ink-testing-library';
import React from 'react';
import { createAskTool } from '../src/ask';
import { applySubagentEvent } from '../src/ui/App';
import { AskPanel, createAskBridge } from '../src/ui/Ask';
import { Markdown } from '../src/ui/Markdown';
import { SubagentPanel, TodoPanel, StatusBar, InfoPanel, type SubagentView } from '../src/ui/Panels';
import type { SubagentEvent } from '../src/subagent';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
test('markdown renders headings, bullets, and code distinctly', () => {
const app = render(<Markdown text={'# Title\n\n- item\n\n```ts\ncode();\n```'} />);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('Title');
expect(frame).toContain('item');
expect(frame).toContain('code();');
expect(frame).toContain('ts');
app.unmount();
});
test('markdown strips the markup characters from the rendered output', () => {
const app = render(<Markdown text={'Use **bold** and `code` here.'} />);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('bold');
expect(frame).toContain('code');
expect(frame).not.toContain('**');
expect(frame).not.toContain('`');
app.unmount();
});
test('the todo panel shows a progress bar and every status', () => {
const app = render(
<TodoPanel
todos={[
{ content: 'first', status: 'done' },
{ content: 'second', status: 'in_progress' },
{ content: 'third', status: 'pending' },
{ content: 'fourth', status: 'blocked', note: 'waiting on the API key' },
]}
width={10}
/>,
);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('1/4');
expect(frame).toContain('1 blocked');
expect(frame).toContain('[x] first');
expect(frame).toContain('[~] second');
expect(frame).toContain('[ ] third');
expect(frame).toContain('[!] fourth');
expect(frame).toContain('waiting on the API key');
app.unmount();
});
test('the subagent panel shows the last few steps of a running agent', () => {
const agents: SubagentView[] = [
{
id: 'sub1',
kind: 'explore',
description: 'find auth handlers',
steps: [
{ tool: 'glob', summary: 'src/**/*.ts' },
{ tool: 'grep', summary: 'login' },
{ tool: 'read_file', summary: 'src/auth.ts' },
],
status: 'running',
},
];
const app = render(<SubagentPanel agents={agents} />);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('explore');
expect(frame).toContain('find auth handlers');
expect(frame).toContain('3 steps');
expect(frame).toContain('grep(login)');
app.unmount();
});
test('a failed subagent shows its error', () => {
const app = render(
<SubagentPanel
agents={[{ id: 's', kind: 'review', description: 'review diff', steps: [], status: 'failed', error: 'model refused' }]}
/>,
);
expect(app.lastFrame()).toContain('model refused');
app.unmount();
});
test('an empty subagent panel renders nothing', () => {
const app = render(<SubagentPanel agents={[]} />);
expect(app.lastFrame() ?? '').toBe('');
app.unmount();
});
test('the status bar reports model, agent, thinking, context, and spend', () => {
const app = render(
<StatusBar model="gpt-5" agent="deep" thinking="max" contextTokens={1234} cost="$0.42" toolCount={13} />,
);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('gpt-5');
expect(frame).toContain('deep');
expect(frame).toContain('max');
expect(frame).toContain('1234');
expect(frame).toContain('$0.42');
expect(frame).toContain('13 tools');
app.unmount();
});
test('the info panel renders a markdown body', () => {
const app = render(<InfoPanel title="tools" hint="7 offered" lines={'- `read_file`\n- `bash`'} />);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('tools');
expect(frame).toContain('7 offered');
expect(frame).toContain('read_file');
expect(frame).not.toContain('`');
app.unmount();
});
const events: SubagentEvent[] = [
{ type: 'start', id: 'a', kind: 'explore', description: 'first task' },
{ type: 'step', id: 'a', tool: 'grep', summary: 'needle' },
{ type: 'start', id: 'b', kind: 'review', description: 'second task' },
{ type: 'end', id: 'a', ok: true, steps: 1 },
{ type: 'error', id: 'b', message: 'exploded' },
];
test('subagent events fold into the panel view', () => {
const view = events.reduce(applySubagentEvent, [] as SubagentView[]);
expect(view).toHaveLength(2);
expect(view[0]).toMatchObject({ id: 'a', status: 'done', steps: [{ tool: 'grep', summary: 'needle' }] });
expect(view[1]).toMatchObject({ id: 'b', status: 'failed', error: 'exploded' });
});
test('an event for an unknown id is ignored rather than throwing', () => {
const view = applySubagentEvent([], { type: 'step', id: 'ghost', tool: 'grep', summary: 'x' });
expect(view).toEqual([]);
});
test('the ask tool refuses when nothing can answer', async () => {
const t = createAskTool(undefined);
expect(
Promise.resolve(t.execute!({ question: 'which one?' } as never, { toolCallId: 'x', messages: [] } as never)),
).rejects.toThrow(/headless/);
});
test('the ask tool returns the chosen options', async () => {
const t = createAskTool(async () => ['Option B']);
const out = await (t.execute!(
{ question: 'A or B?', options: [{ label: 'Option A' }, { label: 'Option B' }] } as never,
{ toolCallId: 'x', messages: [] } as never,
) as Promise<string>);
expect(out).toContain('Option B');
});
test('a dismissed question tells the model to decide for itself', async () => {
const t = createAskTool(async () => undefined);
const out = await (t.execute!({ question: 'anything?' } as never, {
toolCallId: 'x',
messages: [],
} as never) as Promise<string>);
expect(out).toContain('dismissed');
expect(out).toContain('best judgement');
});
test('the ask bridge resolves undefined when no UI is bound', async () => {
const bridge = createAskBridge();
expect(await bridge.ask({ question: 'q', multiple: false })).toBeUndefined();
});
test('the ask panel shows the question and its options', () => {
const app = render(
<AskPanel
pending={{
req: {
question: 'Which storage layer?',
options: [
{ label: 'Postgres', detail: 'relational, needs a migration' },
{ label: 'SQLite', detail: 'zero setup' },
],
multiple: false,
},
resolve: () => {},
}}
/>,
);
const frame = app.lastFrame() ?? '';
expect(frame).toContain('shiro is asking');
expect(frame).toContain('Which storage layer?');
expect(frame).toContain('Postgres');
expect(frame).toContain('SQLite');
app.unmount();
});
test('choosing an option resolves the question', async () => {
const answers: (string[] | undefined)[] = [];
const app = render(
<AskPanel
pending={{
req: { question: 'A or B?', options: [{ label: 'A' }, { label: 'B' }], multiple: false },
resolve: (a) => answers.push(a),
}}
/>,
);
await wait(120);
app.stdin.write('\r');
await wait(200);
expect(answers).toEqual([['A']]);
app.unmount();
}, 10_000);
test('esc dismisses the question with no answer', async () => {
const answers: (string[] | undefined)[] = [];
const app = render(
<AskPanel
pending={{
req: { question: 'anything?', options: [{ label: 'A' }], multiple: false },
resolve: (a) => answers.push(a),
}}
/>,
);
await wait(120);
app.stdin.write('\u001B');
await wait(200);
expect(answers).toEqual([undefined]);
app.unmount();
}, 10_000);
test('a question with no options goes straight to free text', async () => {
const answers: (string[] | undefined)[] = [];
const app = render(
<AskPanel pending={{ req: { question: 'name it', multiple: false }, resolve: (a) => answers.push(a) }} />,
);
await wait(120);
expect(app.lastFrame()).toContain('ype your answer');
for (const ch of 'shiro') {
app.stdin.write(ch);
await wait(40);
}
app.stdin.write('\r');
await wait(200);
expect(answers).toEqual([['shiro']]);
app.unmount();
}, 15_000);