import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import vm from 'node:vm'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); function browser({ authenticated = true, role = 'user', bridgeDelay = 0 } = {}) { let time = 0, nextId = 0, sequence = 0; const timers = new Map(), nodes = new Map(), listeners = new Map(); const calls = { pings: 0, heartbeats: 0, ai: 0, tasks: 0 }; const state = { authenticated, role, bridgeDelay, bridgeAvailable: true, executionReady: true, holdTasks: false }; const storage = { getItem: () => null, setItem() {}, removeItem() {} }; const addListener = (target, type, callback) => { const key = `${target}:${type}`; listeners.set(key, [...(listeners.get(key) || []), callback]); }; const node = selector => { if (!nodes.has(selector)) nodes.set(selector, { value: '', textContent: '', hidden: false, disabled: false, dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, addEventListener: (type, callback) => addListener(selector, type, callback), setAttribute() {}, querySelectorAll: () => [], querySelector: child => node(`${selector} ${child}`), }); return nodes.get(selector); }; const schedule = (callback, delay, interval = false) => { const id = ++nextId; timers.set(id, { callback, due: time + delay, interval: interval ? delay : 0 }); return id; }; const document = { visibilityState: 'visible', querySelector: node, querySelectorAll: () => [], addEventListener: (type, callback) => addListener('document', type, callback), }; const window = { location: { pathname: '/', origin: 'http://fixture.invalid', replace() {} }, setTimeout: (callback, delay) => schedule(callback, delay), clearTimeout: id => timers.delete(id), addEventListener: (type, callback) => addListener('window', type, callback), postMessage(message) { assert.equal(message.type, 'PING', 'status tests must never dispatch a task'); calls.pings++; if (!state.bridgeAvailable) return; schedule(() => { for (const callback of listeners.get('window:message') || []) callback({ source: window, data: { source: 'LTJT_ORDER_ASSISTANT_EXTENSION', requestId: message.requestId, type: 'PONG', payload: { ok: true, version: '99.0.0', bridge_installed_at: 'fixture', erp_session: { account_matched: true, session_ready: true, host_permission_granted: true }, }, } }); }, state.bridgeDelay); }, }; const context = vm.createContext({ window, document, sessionStorage: storage, localStorage: storage, setTimeout: window.setTimeout, clearTimeout: window.clearTimeout, setInterval: (callback, delay) => schedule(callback, delay, true), clearInterval: id => timers.delete(id), crypto: { randomUUID: () => `fixture-${++sequence}` }, Headers, AbortController, URL, URLSearchParams, queueMicrotask, TextEncoder, Date, calls, fixtureState: state, fetch: async resource => { let status = 200, data = { ok: true }; if (resource === '/api/auth/me') { status = state.authenticated ? 200 : 401; data = { user: { id: 'fixture-user', username: 'fixture', role: state.role, erp_account: 'fixture-erp' } }; } else if (resource === '/api/auth/csrf') data.csrf_token = 'fixture-token'; else if (resource === '/api/auth/login') { state.authenticated = true; data = { csrf_token: 'fixture-token', user: { id: 'fixture-user', username: 'fixture', role: state.role, erp_account: 'fixture-erp' } }; } else if (resource === '/api/connections/heartbeat') { calls.heartbeats++; data.execution_ready = state.executionReady; } else if (resource === '/api/status') { calls.ai++; data.ai_connected = true; } else throw new Error(`unexpected status-test API: ${resource}`); return { ok: status === 200, status, json: async () => data }; }, }); // Execute real page bootstrap, auth, bridge messaging and scheduler code. // Only unrelated rendering and business-list execution are stubbed. vm.runInContext(source, context); vm.runInContext(` configurePageMode = () => {}; renderTaskCards = renderTaskDetail = renderStatusDetails = setOutput = () => {}; startRemoteEventStream = startPolling = stopPolling = () => {}; autoDispatchReadyTasks = async () => {}; syncAutomationSettings = async () => {}; syncRemoteTasks = async () => { calls.tasks++; if (fixtureState.holdTasks) await new Promise(() => {}); }; `, context); const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; async function advance(ms = 0) { const end = time + ms; await flush(); while (true) { const next = [...timers].filter(([, timer]) => timer.due <= end).sort((a, b) => a[1].due - b[1].due)[0]; if (!next) break; const [id, timer] = next; time = timer.due; if (timer.interval) timer.due += timer.interval; else timers.delete(id); timer.callback(); await flush(); } time = end; await flush(); } return { state, calls, context, timers, listeners, node, advance, status: () => node('#bridgeState .status-icon-value').textContent, async boot() { await listeners.get('document:DOMContentLoaded')[0](); await advance(); }, async login() { const result = listeners.get('#loginForm:submit')[0]({ preventDefault() {} }); await advance(state.bridgeDelay); await result; }, emit(type, payload = {}) { for (const callback of listeners.get(`window:${type}`) || []) callback(type === 'message' ? { source: window, data: { source: 'LTJT_ORDER_ASSISTANT_EXTENSION', ...payload } } : payload); }, }; } test('login after an unauthenticated page load starts ongoing status recovery without reload', async () => { const b = browser({ authenticated: false }); await b.boot(); await b.advance(30_000); assert.equal(b.calls.pings, 0); await b.login(); assert.equal(b.status(), '已连接'); b.state.bridgeAvailable = false; await b.advance(45_000); assert.equal(b.status(), '未连接'); b.state.bridgeAvailable = true; await b.advance(30_000); assert.equal(b.status(), '已连接'); assert.ok(b.calls.heartbeats >= 2); }); test('a pending task-list refresh does not stop bridge health or subsequent heartbeat recovery', async () => { const b = browser(); await b.boot(); b.state.holdTasks = true; b.state.bridgeAvailable = false; await b.advance(45_000); assert.equal(b.status(), '未连接'); const attempts = b.calls.pings; b.state.bridgeAvailable = true; await b.advance(30_000); assert.ok(b.calls.pings > attempts, 'task synchronization must not hold the bridge scheduler'); assert.equal(b.status(), '已连接'); }); test('a two-second worker/ERP response stays connected and reaches the server heartbeat', async () => { const b = browser({ bridgeDelay: 2_000 }); await b.boot(); await b.advance(2_000); assert.equal(b.status(), '已连接'); assert.equal(b.calls.heartbeats, 1); }); test('simultaneous focus and manual checks coalesce into one account-bound probe', async () => { const b = browser(); await b.boot(); b.state.bridgeDelay = 2_000; const before = b.calls.pings; b.emit('focus'); b.emit('focus'); const first = vm.runInContext('pingBridge()', b.context); const second = vm.runInContext('pingBridge()', b.context); await b.advance(2_000); assert.equal(await first, true); assert.equal(await second, true); assert.equal(b.calls.pings - before, 1); }); test('late status replies after logout cannot publish a heartbeat or restore connected status', async () => { const b = browser({ bridgeDelay: 2_000 }); await b.boot(); vm.runInContext("showLoginPanel(); setBridgeState('未连接', 'state-bad');", b.context); await b.advance(2_000); assert.equal(b.calls.heartbeats, 0); assert.equal(b.status(), '未连接'); }); test('bridge-ready announcement refreshes account-bound state and never accepts an unbound snapshot', async () => { const b = browser(); await b.boot(); const before = b.calls.pings; b.emit('message', { type: 'BRIDGE_READY', payload: { ok: true, version: '99.0.0', erp_session: { account_matched: false } } }); await b.advance(); assert.equal(b.calls.pings, before + 1); assert.equal(b.status(), '已连接'); }); test('an admin page never sends a plugin probe or worker heartbeat', async () => { const b = browser({ role: 'admin' }); await b.boot(); await b.advance(90_000); b.emit('focus'); await b.advance(); assert.equal(b.calls.pings, 0); assert.equal(b.calls.heartbeats, 0); }); test('repeated login retains one timer and one focus listener; logout makes polling inert', async () => { const b = browser({ authenticated: false }); await b.boot(); for (let i = 0; i < 2; i++) { await b.login(); assert.equal(b.status(), '已连接'); vm.runInContext('showLoginPanel()', b.context); const count = b.calls.pings; await b.advance(60_000); assert.equal(b.calls.pings, count); } assert.equal([...b.timers.values()].filter(timer => timer.interval === 30_000).length, 1); assert.equal(b.listeners.get('window:focus').length, 1); assert.equal(b.listeners.get('document:visibilitychange').length, 1); }); test('server worker rejection remains a warning until a later verified heartbeat recovers', async () => { const b = browser(); b.state.executionReady = false; await b.boot(); assert.equal(b.status(), '已连接,有告警'); b.state.executionReady = true; await b.advance(30_000); assert.equal(b.status(), '已连接'); }); test('an old session response cannot overwrite a newer session check or heartbeat identity', async () => { const b = browser(); await b.boot(); b.state.bridgeDelay = 2_000; const old = vm.runInContext('pingBridge()', b.context); vm.runInContext("authUser = { id: 'next-user', role: 'user', erp_account: 'next-erp' }; browserConnectionId = 'next-connection';", b.context); b.state.bridgeDelay = 0; const current = vm.runInContext('pingBridge()', b.context); await b.advance(); assert.equal(await current, true); const count = b.calls.heartbeats; await b.advance(2_000); assert.equal(await old, false); assert.equal(b.calls.heartbeats, count); assert.equal(b.status(), '已连接'); });