674 lines
21 KiB
JavaScript
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;
|