* refactor: split Linux egui host from shared backend * fix: keep CI contracts portable across runners * fix(ci): preserve CRLF while removing qwen dependency * fix(ci): regenerate target lockfile and install ripgrep * fix(ci): gate desktop-only selection voice module * ci: limit native build parallelism on cross-platform runners * fix(host): expose blocking task seam to coordinator * fix(settings): avoid macOS default hotkey collision * docs: record cross-platform CI evidence * ci: expose Linux validation package artifact * ci: grant reusable Linux package permissions * refactor: remove legacy coordinator dictation facade * ci: skip unsupported MSI prerelease builds * ci: install minisign only for signed Linux releases * fix: keep test-only coordinator session helper * refactor: move style preview diagnostics into core * fix: import shared preferences type from core * refactor: centralize enabled vocabulary projection * ci: install ripgrep for Linux package gates * test: make source contract line-ending agnostic * fix: keep Linux desktop entry standards compliant * docs: expose core style diagnostics contract * ci: resolve Linux package version in app directory * refactor: centralize ASR vocabulary priority * fix: use shared vocabulary note constant * refactor: keep vocabulary priority inside core * test: retain starting-stop queue coverage * ci: scope Linux updater URL to the current repository * docs: close implementation and artifact evidence * docs: record final CI artifact evidence * fix(android): align updater plugin versions * fix(android): pin updater crate exactly * refactor(core): remove unused type anchor * docs: record final artifact runs * fix(remote-input): preserve beta status fields * fix(tauri): finish beta host merge * fix(asr): pass scoped operation to native qwen adapter * fix(macos): bundle mlx metallib as a resource * test(remote-input): update status wire contract * feat: share Less Computer voice flow with Linux * refactor(core): centralize host-independent runtime policy * refactor(core): complete 2.0 backend consolidation * fix(ci): prepare Sherpa after cache restore * fix(macos): route native model paths through Core store * fix(macos): use ModelStore catalog API * fix(ci): serialize macOS native test build * test(ci): preserve macOS cargo diagnostics * fix(macos): release input source lock before await * test(core): replace legacy hotkey injection gate * test(core): move Codex sandbox contract into Core * fix(core): close final runtime policy gaps * fix(windows): terminate coding agent process trees * fix(core): complete local model migration * fix(credentials): align Core LLM model account * fix(core): restore 1.x parity across shared backend * fix(android): complete shared backend stubs * fix: close cross-platform migration review gaps * fix: complete migration lifecycles and platform compatibility * docs: define desktop 2.0 acceptance and Linux handoff * fix: close desktop parity and session lifecycle gaps * fix: finish voice lifecycle and desktop feedback parity * fix: close desktop cancellation and feedback race gaps * fix: honor proxy settings and reject stale session effects * fix: dispatch native cleanup on host runtimes and order events * fix: keep optional recording effects best effort * fix: close desktop agent model and QA lifecycle gaps * fix: preserve desktop raw voice and QA turn ownership * fix: finalize desktop dictation and QA feedback
263 lines
8.6 KiB
JavaScript
263 lines
8.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { runInNewContext } from 'node:vm';
|
|
|
|
const source = await readFile(
|
|
new URL('../src-tauri/src/remote_server/assets/app.js', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const html = await readFile(
|
|
new URL('../src-tauri/src/remote_server/assets/index.html', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
function fakeElement() {
|
|
const classes = new Set();
|
|
return {
|
|
listeners: {},
|
|
style: {},
|
|
hidden: false,
|
|
checked: true,
|
|
value: '',
|
|
textContent: '',
|
|
classList: {
|
|
add: (...names) => names.forEach((name) => classes.add(name)),
|
|
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
|
toggle: (name, enabled) => (enabled ? classes.add(name) : classes.delete(name)),
|
|
contains: (name) => classes.has(name),
|
|
},
|
|
addEventListener(type, listener) { this.listeners[type] = listener; },
|
|
querySelectorAll() { return []; },
|
|
focus() {},
|
|
select() {},
|
|
};
|
|
}
|
|
|
|
async function openRemotePage({ defaultMode, savedMode } = {}) {
|
|
const elements = new Map();
|
|
const documentListeners = {};
|
|
const sent = [];
|
|
let socket;
|
|
let worklet;
|
|
|
|
const element = (id) => {
|
|
if (!elements.has(id)) elements.set(id, fakeElement());
|
|
return elements.get(id);
|
|
};
|
|
const storage = (entries = []) => {
|
|
const values = new Map(entries);
|
|
return {
|
|
getItem: (key) => values.get(key) ?? null,
|
|
setItem: (key, value) => values.set(key, String(value)),
|
|
removeItem: (key) => values.delete(key),
|
|
};
|
|
};
|
|
|
|
class FakeWebSocket {
|
|
constructor() {
|
|
this.readyState = 1;
|
|
socket = this;
|
|
}
|
|
send(value) { sent.push(value); }
|
|
close() { this.readyState = 3; }
|
|
}
|
|
|
|
class FakeAudioWorkletNode {
|
|
constructor() {
|
|
this.port = { onmessage: null };
|
|
worklet = this;
|
|
}
|
|
connect() {}
|
|
disconnect() {}
|
|
}
|
|
|
|
class FakeAudioContext {
|
|
constructor() {
|
|
this.state = 'running';
|
|
this.sampleRate = 48_000;
|
|
this.audioWorklet = { addModule: () => Promise.resolve() };
|
|
}
|
|
resume() { return Promise.resolve(); }
|
|
suspend() { this.state = 'suspended'; }
|
|
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
|
|
}
|
|
|
|
const document = {
|
|
hidden: false,
|
|
title: '',
|
|
body: { appendChild() {}, removeChild() {} },
|
|
getElementById: element,
|
|
querySelectorAll: () => [],
|
|
createElement: fakeElement,
|
|
addEventListener(type, listener) { documentListeners[type] = listener; },
|
|
removeEventListener() {},
|
|
execCommand() {},
|
|
};
|
|
const context = {
|
|
ArrayBuffer,
|
|
Blob,
|
|
DataView,
|
|
Error,
|
|
Math,
|
|
Promise,
|
|
Uint8Array,
|
|
URL: { createObjectURL: () => 'blob:worklet' },
|
|
AudioContext: FakeAudioContext,
|
|
AudioWorkletNode: FakeAudioWorkletNode,
|
|
WebSocket: FakeWebSocket,
|
|
clearTimeout,
|
|
console,
|
|
document,
|
|
isNaN,
|
|
localStorage: storage([
|
|
['ol_remote_pin', '123456'],
|
|
...(savedMode === undefined ? [] : [['ol_remote_mode', savedMode]]),
|
|
]),
|
|
location: { host: 'localhost:8443', origin: 'https://localhost:8443', reload() {} },
|
|
navigator: {
|
|
language: 'zh-CN',
|
|
mediaDevices: {
|
|
getUserMedia: () => Promise.resolve({ getTracks: () => [{ stop() {} }] }),
|
|
},
|
|
},
|
|
performance: { now: () => 100 },
|
|
sessionStorage: storage([['ol_reloaded_once', '1']]),
|
|
setTimeout,
|
|
};
|
|
context.window = context;
|
|
// Exercise the embedded HTML script too, so a missing template variable cannot
|
|
// be hidden by setting window properties directly in the test harness.
|
|
const injectedScript = html.match(/<script>([\s\S]*?)<\/script>/)[1]
|
|
.replaceAll('%%OL_LANG%%', 'zh-CN')
|
|
.replaceAll('%%OL_DEFAULT_MODE%%', defaultMode ?? '');
|
|
runInNewContext(injectedScript, context, { filename: 'remote-server/assets/index.html' });
|
|
runInNewContext(source, context, { filename: 'remote-server/assets/app.js' });
|
|
|
|
socket.onopen();
|
|
socket.onmessage({ data: JSON.stringify({ type: 'auth', ok: true }) });
|
|
|
|
return {
|
|
document,
|
|
documentListeners,
|
|
element,
|
|
sent,
|
|
socket,
|
|
storage: context.localStorage,
|
|
async start() {
|
|
element('btn-record').listeners.click();
|
|
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
|
assert.ok(worklet?.port.onmessage, 'audio capture must be running');
|
|
},
|
|
pcm(bytes) { worklet.port.onmessage({ data: Uint8Array.from(bytes).buffer }); },
|
|
};
|
|
}
|
|
|
|
// Exercise the displayed page, not a copied mode resolver: a new phone follows
|
|
// the PC setting, while a mode explicitly saved on that phone takes priority.
|
|
for (const [defaultMode, savedMode, expected] of [
|
|
['hold', undefined, 'hold'],
|
|
['toggle', undefined, 'toggle'],
|
|
['hold', 'toggle', 'toggle'],
|
|
['toggle', 'hold', 'hold'],
|
|
['hold', 'invalid', 'hold'],
|
|
['invalid', undefined, 'toggle'],
|
|
[undefined, undefined, 'toggle'],
|
|
]) {
|
|
const page = await openRemotePage({ defaultMode, savedMode });
|
|
assert.equal(
|
|
page.element('btn-record').style.touchAction,
|
|
expected === 'hold' ? 'none' : 'manipulation',
|
|
`PC default ${defaultMode}, phone choice ${savedMode} must use ${expected}`,
|
|
);
|
|
assert.equal(page.storage.getItem('ol_remote_mode'), savedMode ?? null,
|
|
'inheriting a PC default must not create a phone override');
|
|
}
|
|
|
|
{
|
|
const page = await openRemotePage({ defaultMode: 'hold' });
|
|
page.element('mode-switch').listeners.click({
|
|
target: { closest: () => ({ getAttribute: () => 'toggle' }) },
|
|
});
|
|
assert.equal(page.storage.getItem('ol_remote_mode'), 'toggle');
|
|
assert.equal(page.element('btn-record').style.touchAction, 'manipulation');
|
|
}
|
|
|
|
const binaryFrames = (sent) => sent.filter((value) => value instanceof ArrayBuffer);
|
|
const payload = (frame) => Array.from(new Uint8Array(frame, 28));
|
|
const sequence = (frame) => {
|
|
const view = new DataView(frame);
|
|
return view.getUint32(20, false) * 0x100000000 + view.getUint32(24, false);
|
|
};
|
|
|
|
{
|
|
const page = await openRemotePage();
|
|
await page.start();
|
|
page.pcm([1, 0, 2, 0]);
|
|
page.pcm([3, 0]);
|
|
assert.equal(binaryFrames(page.sent).length, 0, 'PCM must wait for the start ACK');
|
|
assert.equal(page.element('status-text').textContent, '后端准备中…');
|
|
|
|
page.socket.onmessage({
|
|
data: JSON.stringify({ type: 'started', sessionId: '00112233-4455-6677-8899-aabbccddeeff' }),
|
|
});
|
|
const frames = binaryFrames(page.sent);
|
|
assert.deepEqual(frames.map(sequence), [0, 1]);
|
|
assert.deepEqual(frames.map(payload), [[1, 0, 2, 0], [3, 0]]);
|
|
}
|
|
|
|
{
|
|
const page = await openRemotePage();
|
|
await page.start();
|
|
page.pcm([4, 0, 5, 0]);
|
|
page.element('btn-record').listeners.click();
|
|
assert.equal(binaryFrames(page.sent).length, 0);
|
|
assert.equal(page.sent.some((value) => typeof value === 'string' && JSON.parse(value).type === 'stop'), false);
|
|
|
|
page.socket.onmessage({
|
|
data: JSON.stringify({ type: 'started', sessionId: '10112233-4455-6677-8899-aabbccddeeff' }),
|
|
});
|
|
const frameIndex = page.sent.findIndex((value) => value instanceof ArrayBuffer);
|
|
const stopIndex = page.sent.findIndex(
|
|
(value) => typeof value === 'string' && JSON.parse(value).type === 'stop',
|
|
);
|
|
assert.ok(frameIndex >= 0 && stopIndex > frameIndex, 'queued PCM must be sent before stop');
|
|
assert.deepEqual(payload(page.sent[frameIndex]), [4, 0, 5, 0]);
|
|
assert.equal(page.element('status-text').textContent, '识别中');
|
|
page.socket.onmessage({ data: JSON.stringify({ type: 'status', kind: 'error' }) });
|
|
}
|
|
|
|
for (const terminal of ['cancel', 'busy', 'disconnect']) {
|
|
const page = await openRemotePage();
|
|
await page.start();
|
|
page.pcm([6, 0]);
|
|
if (terminal === 'cancel') {
|
|
page.document.hidden = true;
|
|
page.documentListeners.visibilitychange();
|
|
} else if (terminal === 'busy') {
|
|
page.socket.onmessage({ data: JSON.stringify({ type: 'busy', reason: 'test' }) });
|
|
} else {
|
|
page.socket.onclose();
|
|
}
|
|
page.socket.onmessage({
|
|
data: JSON.stringify({ type: 'started', sessionId: '20112233-4455-6677-8899-aabbccddeeff' }),
|
|
});
|
|
assert.equal(binaryFrames(page.sent).length, 0, `${terminal} must discard queued PCM`);
|
|
if (terminal === 'busy') {
|
|
page.socket.onmessage({ data: JSON.stringify({ type: 'status', kind: 'error' }) });
|
|
}
|
|
}
|
|
|
|
{
|
|
const page = await openRemotePage();
|
|
await page.start();
|
|
page.pcm(new Uint8Array(64 * 1024));
|
|
page.pcm(new Uint8Array(64 * 1024));
|
|
assert.equal(page.element('status-text').textContent, '后端准备中…');
|
|
page.pcm([0, 0]);
|
|
assert.match(page.element('status-text').textContent, /音频缓存已满/);
|
|
assert.ok(page.sent.some((value) => typeof value === 'string' && JSON.parse(value).type === 'cancel'));
|
|
assert.equal(binaryFrames(page.sent).length, 0);
|
|
}
|
|
|
|
console.log('remote-input-audio-queue.test.mjs passed');
|