Files
fidoca/.ui-smoke.mjs
2026-09-26 19:19:48 +02:00

121 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');