Update embedded app debugging workflow

This commit is contained in:
zoukai committed 2026-10-22 14:09:49 +08:00
1 parent d50be29246
commit 38807e5f97
13 files changed
+846 -122

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
// 无浏览器依赖的页面逻辑回归;使用 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; });