Files
openless/openless-all/app/scripts/remote-input-audio-queue.test.mjs
T
sim 4ec44b2b0b merge: 汇入 origin/beta 第二轮(#1048 根证书指纹核验、#1049 录音保亮屏)
- 五语 PC/手机页文案取云端指纹核验版;de/es/fr 全套补译(指纹 6 键、保亮屏 4 键、恢复 4 键、中断/离线 2 键)
- index.html:帮助块改为指纹核验流程 + 录音屏新增保亮屏开关(默认开)
- RemoteInputStatus 补 caFingerprintSha256;Linux egui 面板移植指纹显示与复制
- remote-input-audio-queue 测试取云端(#1049 音频队列改造),我方仅格式差异
2026-09-10 17:16:58 +08:00

555 lines
18 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() {},
};
}
const fixtureRecoveryKey = '40112233-4455-4677-8899-aabbccddeeff';
async function openRemotePage({
defaultMode,
savedMode,
savedWakeLock,
savedRecovery,
wakeLockMode = 'supported',
} = {}) {
const elements = new Map();
const documentListeners = {};
const sent = [];
let socket;
let worklet;
let audioContext;
const windowListeners = {};
const wakeLocks = [];
const tracks = [];
const wakeResolvers = [];
let wakeRequests = 0;
const timers = new Map();
let now = 0;
let timerId = 0;
const flush = async () => {
for (let i = 0; i < 12; i += 1) await Promise.resolve();
};
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() {
audioContext = this;
this.state = 'running';
this.sampleRate = 48_000;
this.audioWorklet = { addModule: () => Promise.resolve() };
}
resume() {
this.state = 'running';
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: (id) => timers.delete(id),
console,
document,
isNaN,
localStorage: storage([
['ol_remote_pin', '123456'],
...(savedMode === undefined ? [] : [['ol_remote_mode', savedMode]]),
...(savedWakeLock === undefined ? [] : [['ol_remote_wake_lock', savedWakeLock]]),
...(savedRecovery === undefined
? []
: [
[
'ol_remote_recovery_session',
JSON.stringify({ sessionId: savedRecovery, key: fixtureRecoveryKey }),
],
]),
]),
location: { host: 'localhost:8443', origin: 'https://localhost:8443', reload() {} },
navigator: {
language: 'zh-CN',
wakeLock:
wakeLockMode === 'unsupported'
? undefined
: {
request: (type) => {
assert.equal(type, 'screen');
wakeRequests++;
if (wakeLockMode === 'rejected') return Promise.reject(new Error('system denied'));
const sentinel = {
released: false,
releaseCount: 0,
listener: null,
addEventListener(type, listener) {
assert.equal(type, 'release');
this.listener = listener;
},
release() {
this.released = true;
this.releaseCount++;
this.listener?.();
return Promise.resolve();
},
};
wakeLocks.push(sentinel);
return wakeLockMode === 'deferred'
? new Promise((resolve) => wakeResolvers.push(() => resolve(sentinel)))
: Promise.resolve(sentinel);
},
},
mediaDevices: {
getUserMedia: () => {
const track = {
stopped: false,
stop() {
this.stopped = true;
},
};
tracks.push(track);
return Promise.resolve({ getTracks: () => [track] });
},
},
},
performance: { now: () => 100 },
sessionStorage: storage([['ol_reloaded_once', '1']]),
setTimeout: (callback, delay) => {
const id = ++timerId;
timers.set(id, { callback, at: now + delay });
return id;
},
addEventListener: (type, listener) => {
windowListeners[type] = listener;
},
};
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,
get socket() {
return socket;
},
get audioContext() {
return audioContext;
},
get wakeRequests() {
return wakeRequests;
},
wakeLocks,
tracks,
wakeResolvers,
windowListeners,
flush,
async advance(ms) {
now += ms;
for (const [id, timer] of [...timers]) {
if (timer.at <= now && timers.delete(id)) timer.callback();
}
await flush();
},
storage: context.localStorage,
async start() {
if (element('btn-record').style.touchAction === 'none') {
element('btn-record').listeners.pointerdown({ preventDefault() {} });
} else {
element('btn-record').listeners.click();
}
await flush();
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.element('mode-switch').listeners.click({
target: { closest: () => ({ getAttribute: () => 'hold' }) },
});
} 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);
}
const controls = (page, type) =>
page.sent
.filter((value) => typeof value === 'string')
.map((value) => JSON.parse(value))
.filter((value) => value.type === type);
const recordingId = '30112233-4455-4677-8899-aabbccddeeff';
const acknowledge = (page) =>
page.socket.onmessage({
data: JSON.stringify({
type: 'started',
sessionId: recordingId,
recoveryKey: fixtureRecoveryKey,
}),
});
// 息屏结束两分钟录音,已发送的帧仍在 stop 之前;重复生命周期事件不会重复结束。
for (const defaultMode of ['toggle', 'hold']) {
const page = await openRemotePage({ defaultMode });
assert.equal(page.element('wake-lock-switch').checked, true);
assert.equal(page.storage.getItem('ol_remote_wake_lock'), null);
await page.start();
acknowledge(page);
for (let second = 0; second < 120; second++) page.pcm(new Uint8Array(32_000).fill(second));
page.document.hidden = true;
page.documentListeners.visibilitychange();
page.windowListeners.pagehide();
assert.equal(controls(page, 'stop').length, 1);
assert.equal(controls(page, 'cancel').length, 0);
assert.equal(
binaryFrames(page.sent).reduce((bytes, frame) => bytes + frame.byteLength - 28, 0),
120 * 32_000,
);
assert.equal(JSON.parse(page.sent.at(-1)).type, 'stop');
assert.equal(page.wakeLocks[0].released, true);
assert.deepEqual(JSON.parse(page.storage.getItem('ol_remote_recovery_session')), {
sessionId: recordingId,
key: fixtureRecoveryKey,
});
page.document.hidden = false;
page.documentListeners.visibilitychange();
assert.equal(controls(page, 'start').length, 1, '恢复前台不会自动打开麦克风');
assert.equal(page.wakeRequests, 1);
}
{
const page = await openRemotePage();
await page.start();
page.pcm([7, 0, 8, 0]);
page.document.hidden = true;
page.documentListeners.visibilitychange();
assert.equal(controls(page, 'cancel').length, 0);
acknowledge(page);
assert.deepEqual(payload(binaryFrames(page.sent)[0]), [7, 0, 8, 0]);
assert.equal(JSON.parse(page.sent.at(-1)).type, 'stop', 'ACK 迟到时保留首段音频并按序结束');
}
{
const page = await openRemotePage({ savedWakeLock: '0' });
assert.equal(page.element('wake-lock-switch').checked, false);
await page.start();
assert.equal(page.wakeRequests, 0);
const toggle = page.element('wake-lock-switch');
toggle.checked = true;
toggle.listeners.change();
await page.flush();
assert.equal(page.wakeRequests, 1);
assert.equal(page.storage.getItem('ol_remote_wake_lock'), '1');
toggle.checked = false;
toggle.listeners.change();
assert.equal(page.wakeLocks[0].releaseCount, 1);
assert.equal(page.storage.getItem('ol_remote_wake_lock'), '0');
}
for (const wakeLockMode of ['unsupported', 'rejected']) {
const page = await openRemotePage({ wakeLockMode });
await page.start();
assert.equal(controls(page, 'start').length, 1, '保持亮屏失败不阻止录音');
assert.match(page.element('wake-lock-hint').textContent, /未允许/);
acknowledge(page);
page.audioContext.state = 'interrupted';
page.audioContext.onstatechange();
assert.equal(controls(page, 'stop').length, 1);
assert.equal(controls(page, 'cancel').length, 0);
}
{
const page = await openRemotePage();
await page.start();
acknowledge(page);
page.tracks[0].onended();
assert.equal(controls(page, 'stop').length, 1);
assert.equal(page.tracks[0].stopped, true);
page.socket.onmessage({ data: JSON.stringify({ type: 'status', kind: 'done' }) });
await page.start();
assert.equal(page.tracks.length, 2, '系统中断后的下一次录音必须重新获取麦克风');
assert.equal(page.tracks[1].stopped, false);
}
{
const page = await openRemotePage({ wakeLockMode: 'deferred' });
await page.start();
acknowledge(page);
page.element('btn-record').listeners.click();
page.wakeResolvers[0]();
await page.flush();
assert.equal(page.wakeLocks[0].releaseCount, 1, '录音结束后迟到的请求必须释放');
}
{
const page = await openRemotePage();
await page.start();
await page.wakeLocks[0].release();
assert.match(page.element('wake-lock-hint').textContent, /未允许/);
assert.equal(page.wakeRequests, 1, '系统释放后不能循环申请');
acknowledge(page);
page.socket.readyState = 3;
page.socket.onclose();
page.documentListeners.visibilitychange();
page.socket.onopen();
page.socket.onmessage({ data: JSON.stringify({ type: 'auth', ok: true }) });
assert.equal(controls(page, 'recover').at(-1).sessionId, recordingId);
assert.equal(controls(page, 'recover').at(-1).recoveryKey, fixtureRecoveryKey);
page.socket.onmessage({
data: JSON.stringify({
type: 'recovery',
sessionId: recordingId,
recovery: { kind: 'pending' },
}),
});
assert.equal(page.element('btn-record').disabled, true);
await page.advance(1500);
assert.equal(controls(page, 'recover').length, 2);
page.socket.onmessage({
data: JSON.stringify({
type: 'recovery',
sessionId: recordingId,
recovery: { kind: 'completed', text: '保留下来的两分钟录音' },
}),
});
assert.equal(page.element('result-text').textContent, '保留下来的两分钟录音');
assert.equal(page.element('btn-record').disabled, false);
}
{
const page = await openRemotePage({ savedRecovery: recordingId });
const oldSocket = page.socket;
await page.advance(8000);
assert.notEqual(page.socket, oldSocket, '半开连接恢复超时后重新认证');
assert.equal(oldSocket.readyState, 3);
}
for (const recovery of [{ kind: 'failed', hasAudioRecording: true }, { kind: 'unavailable' }]) {
const page = await openRemotePage({ savedRecovery: recordingId });
page.socket.onmessage({
data: JSON.stringify({ type: 'recovery', sessionId: recordingId, recovery }),
});
assert.match(page.element('status-text').textContent, /历史记录/);
assert.equal(page.element('btn-record').disabled, false);
if (recovery.kind === 'unavailable')
assert.equal(page.storage.getItem('ol_remote_recovery_session'), null);
}
console.log('remote-input-audio-queue.test.mjs passed');