Files
fidoca/.ui-smoke.mjs
2026-09-25 05:37:09 +02:00

96 lines
5.2 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';
// --- 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('(PgUp/PgDn'), '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. live reasoning streams into the scrollable history pane ---
const reasoningStore = createStore();
reasoningStore.setBusy(true);
reasoningStore.setStatus('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 + streaming reasoning');