233 lines
10 KiB
JavaScript
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(), '已连接');
|
|
});
|