Files
openless/openless-all/app/scripts/qa-panel-runtime.test.mjs

674 lines
21 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const app = fileURLToPath(new URL('../', import.meta.url)),
require = createRequire(resolve(app, 'package.json')),
ts = require('typescript');
const helpers = await import(pathToFileURL(resolve(app, 'src/lib/qaMessage.ts')));
const source = readFileSync(resolve(app, 'src/pages/QaPanel.tsx'), 'utf8');
const parsed = ts.createSourceFile(
'QaPanel.tsx',
source,
ts.ScriptTarget.ES2020,
true,
ts.ScriptKind.TSX,
);
const names = parsed.statements
.filter(ts.isImportDeclaration)
.flatMap((n) =>
n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings)
? n.importClause.namedBindings.elements.map((n) => n.name.text)
: [],
);
const body = parsed.statements
.filter((n) => !ts.isImportDeclaration(n))
.map((n) => n.getFullText(parsed))
.join('\n')
.replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})');
const compiled = ts.transpileModule(body, {
compilerOptions: {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const factory = new Function(
...names,
'__listen',
'exports',
'require',
`${compiled};return {QaPanel,Composer,MessageRow,ErrorContent};`,
);
const tick = () => new Promise((resolve) => setImmediate(resolve));
const deferred = () => {
let resolve, reject;
const promise = new Promise((a, b) => {
resolve = a;
reject = b;
});
return { promise, resolve, reject };
};
const jsx = (type, props) => ({ type, props: props ?? {} });
function nodes(tree) {
if (Array.isArray(tree)) return tree.flatMap(nodes);
if (!tree || typeof tree !== 'object' || !tree.props) return [];
return [tree, ...nodes(tree.props.children)];
}
function find(tree, predicate) {
const node = nodes(tree).find(predicate);
assert(node, 'expected control');
return node;
}
let active;
class Hooks {
slots = [];
cursor = 0;
pending = [];
state(initial) {
const i = this.cursor++;
this.slots[i] ??= { value: typeof initial === 'function' ? initial() : initial };
return [
this.slots[i].value,
(value) => {
this.slots[i].value = typeof value === 'function' ? value(this.slots[i].value) : value;
},
];
}
ref(initial) {
return this.state({ current: initial })[0];
}
effect(fn, deps) {
const i = this.cursor++,
prev = this.slots[i];
if (
prev &&
deps &&
prev.deps?.length === deps.length &&
deps.every((v, i) => Object.is(v, prev.deps[i]))
)
return;
const slot = { deps };
this.slots[i] = slot;
this.pending.push(() => {
prev?.cleanup?.();
slot.cleanup = fn();
});
}
render(fn) {
this.cursor = 0;
active = this;
const tree = fn();
this.pending.splice(0).forEach((fn) => fn());
return tree;
}
unmount() {
this.slots.forEach((s) => s.cleanup?.());
}
}
function context(native = true, embedded = false) {
const events = new Map(),
keys = new Set(),
calls = {
resize: [],
submit: [],
submitArgs: [],
snapshot: 0,
mic: 0,
mode: [],
preview: [],
confirm: [],
revert: [],
dismiss: 0,
close: 0,
};
const pending = {};
const lifecycle = { enterEpoch: 0, closing: false };
globalThis.window = {
location: { search: '?window=qa&demo=1' },
addEventListener: (name, fn) => {
if (name === 'keydown') keys.add(fn);
},
removeEventListener: (name, fn) => {
if (name === 'keydown') keys.delete(fn);
},
};
const imports = Object.fromEntries(names.map((n) => [n, n]));
Object.assign(imports, {
useState: (i) => active.state(i),
useRef: (i) => active.ref(i),
useEffect: (f, d) => active.effect(f, d),
useTranslation: () => ({ t: (k) => k }),
useGithubLogin: () => '',
useChatPanelLifecycle: () => lifecycle,
isTauri: native,
...helpers,
qaWindowSetExpanded: async (next) => {
calls.resize.push(next);
return pending.resize?.(next);
},
qaSubmitText: async (text, session) => {
calls.submit.push(text);
calls.submitArgs.push([text, session]);
return pending.submit?.promise;
},
qaGetSnapshot: async () => {
calls.snapshot++;
return pending.snapshot?.() ?? { kind: 'idle', messages: [] };
},
qaToggleRecording: async () => {
calls.mic++;
return pending.mic?.promise;
},
qaSetEditInstructionMode: async (mode) => {
calls.mode.push(mode);
return pending.mode?.promise;
},
qaWindowDismiss: async () => {
calls.dismiss++;
return pending.dismiss?.promise;
},
getSelectionVoicePreview: async (session) => {
calls.preview.push(session);
return pending.preview?.promise ?? { text: ' fixture edit ' };
},
confirmSelectionVoicePreview: async (...args) => {
calls.confirm.push(args);
return pending.confirm?.promise;
},
revertSelectionVoicePreview: async (session) => {
calls.revert.push(session);
return pending.revert?.promise;
},
chatPanelFocusKeyboard: async () => {},
});
const api = factory(
...names.map((n) => imports[n]),
async (name, fn) => {
const listeners = events.get(name) ?? new Set();
listeners.add(fn);
events.set(name, listeners);
return () => listeners.delete(fn);
},
{},
() => ({ jsx, jsxs: jsx, Fragment: 'fragment' }),
);
const hooks = new Hooks(),
render = () =>
hooks.render(() => api.QaPanel({ embedded, onRequestClose: () => calls.close++ }));
const emit = (name, payload) => {
for (const fn of events.get(name) ?? []) fn({ payload });
};
const composer = (tree) => find(tree, (n) => n.type === api.Composer).props;
return { api, hooks, render, emit, calls, pending, lifecycle, events, keys, composer };
}
let passed = 0,
failed = 0;
async function test(name, fn) {
try {
await fn();
console.log('PASS ' + name);
passed++;
} catch (error) {
console.log('FAIL ' + name + '\n' + error.stack);
failed++;
}
}
await test('browser compact has no demo conversations and cannot execute native actions', async () => {
const c = context(false);
let tree = c.render();
assert(!tree.props.className.includes('is-expanded'));
assert(!nodes(tree).some((n) => n.type === c.api.MessageRow));
let p = c.composer(tree);
p.onChange('fixture');
p = c.composer(c.render());
await p.onSubmit();
await p.onToggleRecording();
await p.onEditInstructionModeChange(true);
await tick();
assert.deepEqual(c.calls.resize, []);
assert.deepEqual(c.calls.submit, []);
assert.equal(c.calls.mic, 0);
assert.deepEqual(c.calls.mode, []);
const h = new Hooks(),
ui = h.render(() => c.api.Composer(p));
for (const button of nodes(ui).filter((n) => n.type === 'button'))
assert.equal(button.props.disabled, true);
c.hooks.unmount();
});
await test('native text waits for expansion and excludes repeated clicks; keeps a newer draft', async () => {
const c = context();
c.render();
await tick();
assert.deepEqual(c.calls.resize, [false]);
c.composer(c.render()).onChange('question');
let p = c.composer(c.render());
const resize = deferred(),
send = deferred();
c.pending.resize = (next) => (next ? resize.promise : undefined);
c.pending.submit = send;
const first = p.onSubmit();
void p.onSubmit();
c.render();
await tick();
assert.deepEqual(c.calls.resize, [false, true]);
assert.deepEqual(c.calls.submit, []);
resize.resolve();
await tick();
assert.deepEqual(c.calls.submit, ['question']);
c.composer(c.render()).onChange('next draft');
send.resolve();
await first;
assert.equal(c.composer(c.render()).value, 'next draft');
c.hooks.unmount();
});
await test('send failure preserves draft and exposes error without a fake user message', async () => {
const c = context();
c.render();
await tick();
c.composer(c.render()).onChange('keep this');
const d = deferred();
c.pending.submit = d;
const result = c.composer(c.render()).onSubmit();
await tick();
d.reject(new Error('fixture'));
await result;
const tree = c.render();
assert.equal(c.composer(tree).value, 'keep this');
assert.equal(c.composer(tree).status, 'error');
assert(!nodes(tree).some((n) => n.type === c.api.MessageRow));
c.hooks.unmount();
});
await test('cancel during resize prevents queued question and late layout failures do not overwrite reopened compact state', async () => {
const c = context();
c.render();
await tick();
c.composer(c.render()).onChange('do not send');
const d = deferred();
c.pending.resize = (next) => (next ? d.promise : undefined);
const submission = c.composer(c.render()).onSubmit();
c.render();
await tick();
await c.composer(c.render()).onClose();
c.lifecycle.closing = true;
c.render();
c.lifecycle.closing = false;
c.lifecycle.enterEpoch++;
c.render();
assert.deepEqual(c.calls.resize, [false, true]);
d.reject(new Error('old geometry'));
await submission;
await tick();
c.render();
await tick();
assert.deepEqual(c.calls.submit, []);
assert.deepEqual(c.calls.resize, [false, true, false]);
assert.equal(c.composer(c.render()).status, 'idle');
assert(!nodes(c.render()).some((n) => n.props.className === 'qa-layout-error'));
c.hooks.unmount();
});
await test('recording level requires active session; no fake recording before backend phase', async () => {
const c = context();
c.render();
await tick();
const d = deferred();
c.pending.mic = d;
let p = c.composer(c.render());
const first = p.onToggleRecording();
void p.onToggleRecording();
assert.equal(c.calls.mic, 1);
p = c.composer(c.render());
assert.equal(p.status, 'idle');
assert.equal(p.micBusy, true);
assert.equal(p.level, 0);
c.emit('qa:state', {
kind: 'recording',
sessionId: 'a',
selectionPreview: 'selected',
messages: [],
});
c.emit('qa:level', { sessionId: 'other', level: 0.9 });
assert.equal(c.composer(c.render()).level, 0);
c.emit('qa:level', { sessionId: 'a', level: 0.6 });
p = c.composer(c.render());
assert.equal(p.level, 0.6);
assert.equal(p.status, 'recording');
assert.equal(p.voiceActive, true);
assert.equal(p.selectionPreview, 'selected');
d.resolve();
await first;
c.emit('qa:state', {
kind: 'thinking',
sessionId: 'a',
messages: [{ role: 'user', content: 'actual question' }],
});
c.emit('qa:level', { sessionId: 'a', level: 0.9 });
p = c.composer(c.render());
assert.equal(p.level, 0);
assert.equal(p.voiceActive, false);
c.hooks.unmount();
});
await test('stale answer is ignored and final answer replaces streaming buffer exactly once', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', {
kind: 'thinking',
sessionId: 'new',
messages: [{ role: 'user', content: 'question' }],
});
c.emit('qa:state', { kind: 'answer_delta', sessionId: 'old', chunk: 'stale' });
c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'real ' });
c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'answer' });
let tree = c.render();
assert.equal(find(tree, (n) => n.type === 'AssistantMarkdown').props.markdown, 'real answer');
c.emit('qa:state', {
kind: 'answer',
sessionId: 'new',
messages: [
{ role: 'user', content: 'question' },
{ role: 'assistant', content: 'real answer' },
],
});
tree = c.render();
assert(!nodes(tree).some((n) => n.type === 'AssistantMarkdown'));
assert.equal(nodes(tree).filter((n) => n.type === c.api.MessageRow).length, 2);
c.hooks.unmount();
});
await test('edit preview captured in old session cannot be applied after a new recording starts', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', {
kind: 'answer',
sessionId: 'a',
messages: [{ role: 'assistant', content: 'preview' }],
editApplyAvailable: true,
editRevertAvailable: true,
});
let tree = c.render();
const d = deferred();
c.pending.preview = d;
find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick();
assert.deepEqual(c.calls.preview, ['a']);
c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] });
d.resolve({ text: 'must not apply' });
await tick();
assert.deepEqual(c.calls.confirm, []);
assert.equal(c.composer(c.render()).status, 'recording');
c.hooks.unmount();
});
await test('an old rendered edit button cannot approve a newer answer before React presents it', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', {
kind: 'answer',
sessionId: 'a',
messages: [{ role: 'assistant', content: 'old preview' }],
editApplyAvailable: true,
});
const oldTree = c.render();
c.emit('qa:state', { kind: 'thinking', sessionId: 'b', messages: [] });
c.emit('qa:state', {
kind: 'answer',
sessionId: 'b',
messages: [{ role: 'assistant', content: 'new unreviewed preview' }],
editApplyAvailable: true,
});
find(oldTree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick();
await tick();
assert.deepEqual(c.calls.preview, []);
assert.deepEqual(c.calls.confirm, []);
c.hooks.unmount();
});
await test('edit apply/revert are single-flight and send exact text plus captured session', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', {
kind: 'answer',
sessionId: 'a',
messages: [{ role: 'assistant', content: 'preview' }],
editApplyAvailable: true,
editRevertAvailable: true,
});
let tree = c.render();
const d = deferred();
c.pending.confirm = d;
const button = find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply');
button.props.onClick();
button.props.onClick();
await tick();
assert.deepEqual(c.calls.confirm, [['fixture edit', 'a']]);
d.resolve();
await tick();
assert(!nodes(c.render()).some((n) => n.props.className === 'qa-edit-actions'));
c.emit('qa:state', {
kind: 'answer',
sessionId: 'a',
messages: [{ role: 'assistant', content: 'preview' }],
editApplyAvailable: true,
editRevertAvailable: true,
});
tree = c.render();
const r = deferred();
c.pending.revert = r;
const back = find(
tree,
(n) => n.type === 'button' && n.props.children === 'qa.editRevertPrevious',
);
back.props.onClick();
back.props.onClick();
assert.deepEqual(c.calls.revert, ['a']);
r.resolve();
await tick();
tree = c.render();
assert(nodes(tree).some((n) => n.props.className === 'qa-apply'));
assert(!nodes(tree).some((n) => n.props.children === 'qa.editRevertPrevious'));
c.hooks.unmount();
});
await test('mode update waits for backend and repeated click cannot flip twice', async () => {
const c = context();
c.render();
await tick();
const d = deferred();
c.pending.mode = d;
let p = c.composer(c.render());
const first = p.onEditInstructionModeChange(true);
void p.onEditInstructionModeChange(false);
p = c.composer(c.render());
assert.equal(p.editInstructionMode, false);
assert.deepEqual(c.calls.mode, [true]);
d.reject(new Error('fixture'));
await first;
p = c.composer(c.render());
assert.equal(p.editInstructionMode, false);
assert.equal(p.status, 'error');
c.hooks.unmount();
});
await test('embedded skip native resize, preserve existing parent close path and event cleanup', async () => {
const c = context(true, true);
c.render();
await tick();
c.composer(c.render()).onChange('embedded');
await c.composer(c.render()).onSubmit();
c.render();
await tick();
assert.deepEqual(c.calls.resize, []);
c.emit('qa:dismiss', {});
assert.equal(c.calls.close, 1);
c.hooks.unmount();
assert.equal(
[...c.events.values()].reduce((n, set) => n + set.size, 0),
0,
);
assert.equal(c.keys.size, 0);
});
await test('composer IME enter, WebKit 229 and form submission guards; genuine enter sends', async () => {
const c = context();
const h = new Hooks();
let sent = 0;
const p = {
value: '中文',
status: 'idle',
level: 0,
voiceActive: false,
selectionPreview: '',
busy: false,
micBusy: false,
embedded: false,
editInstructionMode: false,
onEditInstructionModeChange() {},
onChange() {},
onSubmit() {
sent++;
},
onToggleRecording() {},
onClose() {},
t: (k) => k,
};
const tree = h.render(() => c.api.Composer(p));
const input = find(tree, (n) => n.type === 'input');
const key = (extra = {}) => ({
key: 'Enter',
keyCode: 13,
nativeEvent: { isComposing: false },
preventDefault() {},
...extra,
});
input.props.onCompositionStart();
input.props.onKeyDown(key());
find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} });
input.props.onCompositionEnd();
input.props.onKeyDown(key({ nativeEvent: { isComposing: true } }));
input.props.onKeyDown(key({ keyCode: 229 }));
assert.equal(sent, 0);
input.props.onKeyDown(key());
assert.equal(sent, 1);
});
await test('session drift during delayed native resize cancels old draft', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] });
c.composer(c.render()).onChange('belongs to a');
const d = deferred();
c.pending.resize = (next) => (next ? d.promise : undefined);
const result = c.composer(c.render()).onSubmit();
await tick();
c.emit('qa:state', { kind: 'idle', sessionId: 'b', messages: [] });
d.resolve();
await result;
await tick();
assert.deepEqual(c.calls.submit, []);
c.hooks.unmount();
});
await test('text always carries the exact session captured before resize', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] });
c.composer(c.render()).onChange('question a');
await c.composer(c.render()).onSubmit();
assert.deepEqual(c.calls.submitArgs, [['question a', 'a']]);
c.hooks.unmount();
});
for (const embedded of [false, true])
await test(`cold ${embedded ? 'embedded' : 'desktop'} snapshot recovers initial recording and accepts its levels`, async () => {
const c = context(true, embedded);
c.pending.snapshot = () => ({
kind: 'recording',
sessionId: 'cold',
messages: [],
selectionPreview: 'captured text',
});
c.render();
await tick();
c.emit('qa:level', { sessionId: 'cold', level: 0.4 });
const p = c.composer(c.render());
assert.equal(p.status, 'recording');
assert.equal(p.level, 0.4);
assert.equal(p.selectionPreview, 'captured text');
assert.equal(c.calls.snapshot, 1);
c.hooks.unmount();
});
await test('snapshot reply overtaken by a live session event is discarded and reread', async () => {
const c = context();
const first = deferred();
c.pending.snapshot = () =>
c.calls.snapshot === 1
? first.promise
: { kind: 'recording', sessionId: 'new', messages: [], selectionPreview: 'new' };
c.render();
await tick();
c.emit('qa:state', {
kind: 'recording',
sessionId: 'new',
messages: [],
selectionPreview: 'new',
});
first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' });
await tick();
c.emit('qa:level', { sessionId: 'new', level: 0.7 });
const p = c.composer(c.render());
assert.equal(p.selectionPreview, 'new');
assert.equal(p.level, 0.7);
assert.equal(c.calls.snapshot, 2);
c.hooks.unmount();
});
await test('snapshot completion after dismiss cannot reopen old recording state', async () => {
const c = context();
const first = deferred();
c.pending.snapshot = () => first.promise;
c.render();
await tick();
c.emit('qa:dismiss', {});
c.lifecycle.closing = true;
c.render();
first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' });
await tick();
assert.equal(c.composer(c.render()).status, 'idle');
c.hooks.unmount();
});
await test('a snapshot read failure does not silently remove all established live subscriptions', async () => {
const c = context();
c.pending.snapshot = () => Promise.reject(new Error('transient snapshot unavailable'));
c.render();
await tick();
c.emit('qa:state', { kind: 'recording', sessionId: 'live', messages: [] });
c.emit('qa:level', { sessionId: 'live', level: 0.4 });
const p = c.composer(c.render());
assert.equal(p.status, 'recording');
assert.equal(p.level, 0.4);
c.hooks.unmount();
});
await test('old microphone RPC failure cannot overwrite a newer native recording session', async () => {
const c = context();
c.render();
await tick();
c.emit('qa:state', { kind: 'idle', sessionId: 'before', messages: [] });
const old = deferred();
c.pending.mic = old;
const request = c.composer(c.render()).onToggleRecording();
c.emit('qa:state', { kind: 'recording', sessionId: 'a', messages: [] });
c.emit('qa:state', {
kind: 'error',
sessionId: 'a',
messages: [],
error: 'old recording failed',
});
c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] });
old.reject(new Error('old RPC completed late'));
await request;
const p = c.composer(c.render());
assert.equal(p.status, 'recording');
c.emit('qa:level', { sessionId: 'b', level: 0.8 });
assert.equal(c.composer(c.render()).level, 0.8);
c.hooks.unmount();
});
console.log(JSON.stringify({ passed, failed }));
process.exitCode = failed ? 1 : 0;