Update embedded app debugging workflow
This commit is contained in:
1 parent
d50be29246
commit
38807e5f97
13 files changed
+846
-122
No files matched your search
@@ -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; });
|
||||
Reference in new issue
Block a user