Files
LWLT-AIBOT/LianSyn-platform/app-bridge-status.test.mjs
2026-09-14 17:15:52 +08:00

233 lines
10 KiB
JavaScript

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(), '已连接');
});