209 lines
9.0 KiB
JavaScript
209 lines
9.0 KiB
JavaScript
// 无浏览器依赖的页面逻辑回归;使用 DOM/网络替身,不操作用户界面或真实串口。
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
async function main() {
|
|
const elements = new Map();
|
|
function element(id) {
|
|
if (!elements.has(id)) {
|
|
const classes = new Set();
|
|
elements.set(id, {
|
|
value: '', textContent: '', checked: true, style: {}, dataset: {},
|
|
scrollTop: 0, scrollHeight: 1000, clientHeight: 200,
|
|
selectionStart: 0, selectionEnd: 0, handlers: {},
|
|
classList: {
|
|
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
|
|
remove(name) { classes.delete(name); },
|
|
contains(name) { return classes.has(name); }
|
|
},
|
|
addEventListener(name, callback) { this.handlers[name] = callback; },
|
|
focus() {},
|
|
setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; }
|
|
});
|
|
}
|
|
return elements.get(id);
|
|
}
|
|
const windows = {};
|
|
const sources = [];
|
|
const calls = [];
|
|
const timeouts = new Map();
|
|
let nextTimer = 1;
|
|
let log = '[2026-10-21 16:00:00] localhost login:';
|
|
let hangStatusBody = false;
|
|
let selectedLog = false;
|
|
let selectedElsewhere = false;
|
|
let failLog = false;
|
|
let onLogBody = null;
|
|
let deferFrame = false;
|
|
const frames = [];
|
|
const bytes = text => new TextEncoder().encode(text).buffer;
|
|
const sandbox = {
|
|
console, TextDecoder, TextEncoder, AbortController,
|
|
document: {
|
|
body: { dataset: { token: 'test-token', port: 'COM-TEST' } },
|
|
querySelector: element, querySelectorAll: () => []
|
|
},
|
|
window: {
|
|
crypto: { randomUUID: () => 'test-page' },
|
|
getSelection: () => ({
|
|
isCollapsed: !selectedLog && !selectedElsewhere, rangeCount: 1,
|
|
getRangeAt: () => ({ intersectsNode: node => selectedLog && node === element('#log') })
|
|
}),
|
|
addEventListener(name, callback) { windows[name] = callback; }
|
|
},
|
|
navigator: { sendBeacon: () => true },
|
|
EventSource: class {
|
|
constructor(url) { this.url = url; this.closed = false; sources.push(this); }
|
|
close() { this.closed = true; }
|
|
},
|
|
requestAnimationFrame(callback) { if (deferFrame) frames.push(callback); else callback(); },
|
|
setInterval() {},
|
|
setTimeout(callback) { const id = nextTimer++; timeouts.set(id, callback); return id; },
|
|
clearTimeout(id) { timeouts.delete(id); },
|
|
async fetch(url, options = {}) {
|
|
calls.push({ url, options });
|
|
if (options.method === 'POST') return { ok: true, json: async () => ({ status: 'written' }) };
|
|
if (url.startsWith('/api/status')) {
|
|
return {
|
|
ok: true,
|
|
arrayBuffer: () => hangStatusBody
|
|
? new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))))
|
|
: Promise.resolve(bytes(JSON.stringify({ isOpen: true, port: 'COM-TEST', receivedBytes: 10 })))
|
|
};
|
|
}
|
|
if (failLog) throw new Error('test log failure');
|
|
return { ok: true, arrayBuffer: async () => { if (onLogBody) onLogBody(); return bytes(log); } };
|
|
}
|
|
};
|
|
const context = vm.createContext(sandbox);
|
|
const html = fs.readFileSync(path.join(__dirname, '../assets/serial-console.html'), 'utf8');
|
|
const source = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
|
vm.runInContext(source, context);
|
|
const run = text => vm.runInContext(text, context);
|
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
await flush();
|
|
assert.equal(sources.length, 1);
|
|
assert.match(sources[0].url, /client=test-page&token=test-token/);
|
|
assert.equal(element('#log').scrollTop, 1000, '首次显示最新日志');
|
|
element('#log').scrollTop = 0;
|
|
log += '\nnew line';
|
|
await run('refreshLog()');
|
|
assert.equal(element('#log').scrollTop, 0, '用户向上查看时不抢滚动位置');
|
|
element('#follow').handlers.change();
|
|
assert.equal(element('#log').scrollTop, 1000);
|
|
|
|
// 即使没有新增日志,也不能无条件替换文本节点。
|
|
let displayed = element('#log').textContent;
|
|
let replacements = 0;
|
|
Object.defineProperty(element('#log'), 'textContent', {
|
|
get() { return displayed; },
|
|
set(value) { displayed = value; replacements++; }
|
|
});
|
|
await run('refreshLog()');
|
|
assert.equal(replacements, 0, '相同正文不重建文本节点');
|
|
const heldText = displayed;
|
|
selectedLog = true;
|
|
log += '\narrived while selected';
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, heldText, '选中日志时保留文本节点');
|
|
assert.equal(run('logPolling'), false, '暂停绘制不阻塞下一次轮询');
|
|
element('#log').scrollTop = 20;
|
|
element('#follow').handlers.change();
|
|
assert.equal(element('#log').scrollTop, 20, '有选区时不能自动滚动');
|
|
failLog = true;
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, heldText, '读取失败也不能破坏选区');
|
|
failLog = false;
|
|
selectedLog = false;
|
|
element('#log').handlers.pointerdown({ button: 0 });
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, heldText, '拖选尚未形成非空选区时也保护文本');
|
|
windows.pointerup();
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, log, '松开且取消选区后恢复最新日志');
|
|
selectedElsewhere = true;
|
|
log += '\nselected outside log';
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, log, '选择其他区域不能暂停日志');
|
|
selectedElsewhere = false;
|
|
const beforeRace = displayed;
|
|
log += '\nselection during response';
|
|
onLogBody = () => { selectedLog = true; };
|
|
await run('refreshLog()');
|
|
assert.equal(displayed, beforeRace, '响应等待期间形成的选区也受保护');
|
|
onLogBody = null;
|
|
selectedLog = false;
|
|
element('#log').scrollTop = 1000;
|
|
deferFrame = true;
|
|
await run('refreshLog()');
|
|
assert.ok(frames.length > 0);
|
|
selectedLog = true;
|
|
element('#log').scrollTop = 30;
|
|
for (const callback of frames.splice(0)) callback();
|
|
assert.equal(element('#log').scrollTop, 30, '布局帧等待期间开始选择也不能抢滚动');
|
|
selectedLog = false;
|
|
deferFrame = false;
|
|
element('#log').handlers.pointerdown({ button: 0 });
|
|
windows.blur();
|
|
assert.equal(run('isLogSelectionActive()'), false, '窗口失焦不遗留拖选锁');
|
|
|
|
// 正文读取也必须在超时保护范围内,而不只是拿到响应头。
|
|
hangStatusBody = true;
|
|
const hung = run('refreshStatus()');
|
|
await flush();
|
|
for (const callback of [...timeouts.values()]) callback();
|
|
await hung;
|
|
assert.equal(run('statusPolling'), false);
|
|
hangStatusBody = false;
|
|
await run('refreshStatus()');
|
|
assert.equal(run('serialOpen'), true, '超时后能再次请求并恢复连接状态');
|
|
|
|
log = 'Password:';
|
|
await run('refreshLog()');
|
|
assert.equal(element('#command').classList.contains('masked'), true);
|
|
element('#command').value = 'fake-test-secret';
|
|
await run('sendCommand()');
|
|
assert.equal(run('commandHistory.length'), 0, '密码不进入页面历史');
|
|
log = 'localhost login:';
|
|
await run('refreshLog()');
|
|
element('#command').value = '';
|
|
const commandCalls = () => calls.filter(call => call.url === '/api/send?ending=CR');
|
|
const before = commandCalls().length;
|
|
element('#command').handlers.keydown({ key: 'Enter', isComposing: true });
|
|
assert.equal(commandCalls().length, before, '输入法候选确认不能发送');
|
|
element('#command').handlers.keydown({ key: 'Enter', repeat: true, preventDefault() {} });
|
|
assert.equal(commandCalls().length, before, '长按 Enter 不能重复空发');
|
|
await run('sendCommand()');
|
|
assert.equal(commandCalls().at(-1).options.body, '', '空输入按 CR 接口发送');
|
|
element('#command').value = 'test-command';
|
|
await run('sendCommand()');
|
|
assert.equal(commandCalls().at(-1).options.body, 'test-command');
|
|
assert.equal(element('#command').value, '');
|
|
element('#command').handlers.keydown({ key: 'ArrowUp', preventDefault() {} });
|
|
assert.equal(element('#command').value, 'test-command');
|
|
element('#command').handlers.keydown({ key: 'Escape', preventDefault() {} });
|
|
assert.equal(element('#command').value, '');
|
|
|
|
windows.pagehide();
|
|
assert.equal(sources[0].closed, true);
|
|
const count = calls.length;
|
|
await run('refreshStatus(); refreshLog()');
|
|
assert.equal(calls.length, count, '离页后不能继续轮询把已关闭页面重新登记');
|
|
windows.pageshow();
|
|
await flush();
|
|
assert.equal(sources.length, 2, '从页面缓存恢复时重建当前页面连接');
|
|
assert.equal(sources[1].closed, false);
|
|
windows.pageshow();
|
|
assert.equal(sources.length, 2, '重复 pageshow 不得创建重复连接');
|
|
console.log(JSON.stringify({ passed: true, checks: [
|
|
'liveness lifecycle', 'poll timeout recovery', 'initial/follow scroll',
|
|
'password history exclusion', 'IME/Enter guard', 'empty CR request',
|
|
'command request/history', 'pagehide/pageshow', 'unchanged log DOM retention',
|
|
'selection/drag protection', 'selection during response/frame', 'selection resume'
|
|
] }));
|
|
}
|
|
|
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|