121 lines
6.4 KiB
JavaScript
121 lines
6.4 KiB
JavaScript
import { createElement as h } from 'react';
|
||
import { renderToString } from 'ink';
|
||
import assert from 'node:assert/strict';
|
||
import chalk from 'chalk';
|
||
import { createStore } from './src/ui/store.js';
|
||
import { App } from './src/ui/app.js';
|
||
import { TURN_STATES } from './src/turn-state.js';
|
||
|
||
// --- fixture: exercises header, history, command bar, token footer ---
|
||
function buildMainStore() {
|
||
const store = createStore();
|
||
const RESET = '\x1b[0m', BG = '\x1b[100m', BGCLOSE = '\x1b[49m';
|
||
const paint = (text, width = 40) => text.split('\n').map((l) => {
|
||
const vis = l.replace(/\x1b\[[0-9;]*m/g, '').length;
|
||
const pad = l + ' '.repeat(Math.max(0, width - vis));
|
||
return BG + pad.replace(/\x1b\[0m/g, RESET + BG) + BGCLOSE;
|
||
}).join('\n');
|
||
|
||
store.setMeta({ model: 'smoke-model', workspace: '/tmp/ws', reasoning: 'low' });
|
||
store.log(chalk.cyan.bold(' [banner line]'));
|
||
store.log('\n ' + chalk.green.bold('Assistant') + '\n ' + '-'.repeat(20) + '\n' + paint(' ' + chalk.bold('hello') + ' world'));
|
||
store.addUsage({ input: 1234, output: 567, cached: 100, cost: 0.01234 });
|
||
store.prompt({ type: 'main', message: 'You', prefill: '/rea' });
|
||
return store;
|
||
}
|
||
|
||
// --- 1. main pane layout ---
|
||
const store = buildMainStore();
|
||
const out = renderToString(h(App, { store }), { columns: 80 });
|
||
console.log('----- JSON -----');
|
||
console.log(JSON.stringify(out));
|
||
console.log('----- VISIBLE -----');
|
||
console.log(out);
|
||
|
||
// Strip ANSI for structural assertions.
|
||
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||
const vis = strip(out);
|
||
assert.ok(vis.includes('FIDO'), 'header chrome renders');
|
||
assert.ok(vis.includes('smoke-model'), 'header shows model');
|
||
assert.ok(vis.includes('[banner line]'), 'history renders');
|
||
assert.ok(vis.includes('hello world'), 'history renders painted block');
|
||
assert.ok(vis.includes('/reasoning'), 'command bar shows slash autocomplete');
|
||
assert.ok(vis.includes('in=1,234'), 'footer shows token totals');
|
||
assert.ok(vis.includes('cost=$0.01234'), 'footer shows cost');
|
||
assert.ok(vis.includes('PgUp/PgDn'), 'footer shows scroll hints');
|
||
store.resolve(null); // settle the prompt promise so the process can exit
|
||
|
||
// --- 2. scrolled history hides the live tail, shows the scroll badge ---
|
||
const scrolled = buildMainStore();
|
||
for (let i = 1; i <= 30; i++) scrolled.log(` filler line ${i}`);
|
||
scrolled.scrollBy(10);
|
||
const outScrolled = strip(renderToString(h(App, { store: scrolled }), { columns: 80 }));
|
||
assert.ok(outScrolled.includes('▲ 10 lines'), 'header shows scroll badge when scrolled');
|
||
assert.ok(!outScrolled.includes('filler line 30'), 'scrolled view hides the live tail');
|
||
assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines');
|
||
scrolled.resolve(null);
|
||
|
||
// Wheel decoding is intentionally exercised through the same store movement
|
||
// semantics as the terminal listener: up increases the offset, down decreases.
|
||
const wheel = buildMainStore();
|
||
for (let i = 1; i <= 30; i++) wheel.log(` wheel line ${i}`);
|
||
wheel.scrollBy(3);
|
||
assert.equal(wheel.getState().scroll, 3, 'wheel-up delta moves toward older history');
|
||
wheel.scrollBy(-3);
|
||
assert.equal(wheel.getState().scroll, 0, 'wheel-down delta returns to the live tail');
|
||
wheel.resolve(null);
|
||
|
||
// --- 3. tiny-terminal fallback (rows < 12): plain column, no clipping ---
|
||
// NOTE: renderToString only accepts `columns`, so drive the fallback through
|
||
// App's `sizeOverride` test seam instead of render options.
|
||
const tiny = buildMainStore();
|
||
const outTiny = strip(renderToString(h(App, { store: tiny, sizeOverride: { columns: 80, rows: 8 } }), { columns: 80 }));
|
||
assert.ok(outTiny.includes('[banner line]'), 'tiny fallback still renders history');
|
||
assert.ok(outTiny.includes('in=1,234'), 'tiny fallback still shows totals');
|
||
assert.ok(!outTiny.includes('PgUp/PgDn history'), 'tiny fallback drops fullscreen key hints');
|
||
tiny.resolve(null);
|
||
|
||
// --- 4. the status line renders the label for every non-idle state ---
|
||
// The status line is driven by the connection-state enum, so the smoke test
|
||
// walks the store through each busy state and checks the matching label. The
|
||
// previous fixture hardcoded the free-text 'thinking…'.
|
||
for (const [state, label] of [
|
||
[TURN_STATES.CONNECTING, 'connecting…'],
|
||
[TURN_STATES.THINKING, 'thinking…'],
|
||
[TURN_STATES.WORKING, 'working…'],
|
||
[TURN_STATES.STREAMING, 'streaming…'],
|
||
]) {
|
||
const s = createStore();
|
||
s.setBusy(true);
|
||
s.setTurnState(state);
|
||
const rendered = strip(renderToString(h(App, { store: s, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||
assert.ok(rendered.includes(label), `${state} renders as ${label}`);
|
||
assert.ok(!rendered.includes('› (idle)'), `${state} is not shown as idle`);
|
||
}
|
||
|
||
// The idle placeholder is the only thing shown when no turn is running.
|
||
const idleStore = createStore();
|
||
idleStore.setBusy(false);
|
||
const outIdle = strip(renderToString(h(App, { store: idleStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||
assert.ok(outIdle.includes('› (idle)'), 'idle placeholder renders when not busy');
|
||
|
||
// --- 5. live reasoning streams into the scrollable history pane ---
|
||
const reasoningStore = createStore();
|
||
reasoningStore.setBusy(true);
|
||
reasoningStore.setTurnState(TURN_STATES.THINKING);
|
||
reasoningStore.setReasoningDraft('Analyzing user question\nChecking relevant files');
|
||
|
||
const outLiveReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||
assert.ok(outLiveReasoning.includes('🧠 Reasoning (thinking…)'), 'history pane streams live reasoning with thinking header');
|
||
assert.ok(outLiveReasoning.includes('Analyzing user question'), 'history pane shows line 1 of reasoning');
|
||
assert.ok(outLiveReasoning.includes('Checking relevant files'), 'history pane shows line 2 of reasoning');
|
||
|
||
// Finalizing promotes to permanent entry without "(thinking…)"
|
||
reasoningStore.finalizeReasoning();
|
||
const outFinalReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
|
||
assert.ok(outFinalReasoning.includes('🧠 Reasoning'), 'finalized reasoning has header');
|
||
assert.ok(!outFinalReasoning.includes('🧠 Reasoning (thinking…)'), 'finalized reasoning drops thinking indicator from header');
|
||
assert.ok(outFinalReasoning.includes('Analyzing user question'), 'finalized reasoning preserves content');
|
||
|
||
console.log('\nSMOKE-OK: header/history/command-bar/footer + scroll + tiny fallback + connection states + streaming reasoning');
|