Files
shiro-neko/test/features-ui.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

245 lines
6.4 KiB
TypeScript

import { expect, test } from 'bun:test';
import { render } from 'ink-testing-library';
import React from 'react';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import { Session } from '../src/session';
import { App, createApprovalBridge, type AppHooks } from '../src/ui/App';
import { testHooks } from './helpers';
const usage = {
inputTokens: { total: 3, noCache: 3, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 1 },
} as any;
const model = new MockLanguageModelV4({
doStream: async () =>
({
stream: simulateReadableStream({
chunks: [
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'reply' },
{ type: 'text-end', id: '0' },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
],
chunkDelayInMs: null,
initialDelayInMs: null,
}),
}) as any,
});
function mount(over: Partial<AppHooks> = {}) {
const bridge = createApprovalBridge();
const session = new Session({ model, askApproval: bridge.ask });
const app = render(<App session={session} bridge={bridge} header="hdr" hooks={testHooks(over)} />);
return { app, session };
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const DOWN = '\u001B[B';
async function press(app: ReturnType<typeof render>, s: string, ms = 90) {
app.stdin.write(s);
await wait(ms);
}
async function run(app: ReturnType<typeof render>, command: string, settle = 350) {
for (const ch of command) await press(app, ch, 45);
await press(app, '\r', settle);
}
test('/agent with a name switches without opening a picker', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return `agent is now ${name}`;
},
});
await wait(150);
await run(app, '/agent deep');
expect(picked).toEqual(['deep']);
expect(app.lastFrame()).toContain('agent is now deep');
expect(app.lastFrame()).not.toContain('Choose an agent');
app.unmount();
}, 20_000);
test('/agent with no name opens the picker listing every variant', async () => {
const { app } = mount();
await wait(150);
await run(app, '/agent');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('Choose an agent');
expect(frame).toContain('default');
expect(frame).toContain('quick');
expect(frame).toContain('deep');
expect(frame).toContain('plan');
expect(frame).toContain('review');
app.unmount();
}, 20_000);
test('selecting from the agent picker applies the choice', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return `agent is now ${name}`;
},
});
await wait(150);
await run(app, '/agent');
await press(app, DOWN, 120);
await press(app, '\r', 300);
expect(picked).toEqual(['quick']);
expect(app.lastFrame()).not.toContain('Choose an agent');
app.unmount();
}, 20_000);
test('esc dismisses the agent picker without switching', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return name;
},
});
await wait(150);
await run(app, '/agent');
expect(app.lastFrame()).toContain('Choose an agent');
await press(app, '\u001B', 250);
expect(app.lastFrame()).not.toContain('Choose an agent');
expect(picked).toEqual([]);
app.unmount();
}, 20_000);
test('/think with a level switches directly', async () => {
const levels: string[] = [];
const { app } = mount({
switchThinking: (level) => {
levels.push(level);
return `thinking is now ${level}`;
},
});
await wait(150);
await run(app, '/think max');
expect(levels).toEqual(['max']);
expect(app.lastFrame()).toContain('thinking is now max');
app.unmount();
}, 20_000);
test('/think with no level opens the picker listing the five levels', async () => {
const { app } = mount();
await wait(150);
await run(app, '/think');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('Thinking level');
for (const level of ['off', 'low', 'medium', 'high', 'max']) expect(frame).toContain(level);
app.unmount();
}, 20_000);
test('an invalid thinking level is reported, not applied', async () => {
const { app } = mount({
switchThinking: () => {
throw new Error('Unknown thinking level "ludicrous"');
},
});
await wait(150);
await run(app, '/think ludicrous');
expect(app.lastFrame()).toContain('Unknown thinking level');
app.unmount();
}, 20_000);
test('/skills lists what loaded', async () => {
const { app } = mount({ listSkills: () => 'debug builtin Track down a bug' });
await wait(150);
await run(app, '/skills');
expect(app.lastFrame()).toContain('Track down a bug');
app.unmount();
}, 20_000);
test('/plugins lists what is active', async () => {
const { app } = mount({ listPlugins: () => 'guard refuses irreversible shell commands' });
await wait(150);
await run(app, '/plugins');
expect(app.lastFrame()).toContain('refuses irreversible shell commands');
app.unmount();
}, 20_000);
test('/notes reports the durable memory', async () => {
const { app } = mount({ listMemory: async () => '(command) release with bun run release' });
await wait(150);
await run(app, '/notes');
expect(app.lastFrame()).toContain('release with bun run release');
app.unmount();
}, 20_000);
test('/memory compacts and reports the result', async () => {
let called = 0;
const { app } = mount({
summarizeMemory: async () => {
called++;
return 'memory compacted: 40 entries into 12';
},
});
await wait(150);
await run(app, '/memory', 600);
expect(called).toBe(1);
expect(app.lastFrame()).toContain('40 entries into 12');
app.unmount();
}, 20_000);
test('a failing /memory surfaces the error instead of crashing', async () => {
const { app } = mount({
summarizeMemory: async () => {
throw new Error('no model available to summarize memory');
},
});
await wait(150);
await run(app, '/memory', 600);
expect(app.lastFrame()).toContain('no model available');
app.unmount();
}, 20_000);
test('the prompt input returns after a picker closes', async () => {
const { app } = mount();
await wait(150);
await run(app, '/think');
expect(app.lastFrame()).not.toContain('sk shiro-neko');
await press(app, '\u001B', 250);
expect(app.lastFrame()).toContain('sk shiro-neko');
app.unmount();
}, 20_000);