心跳问题修复

This commit is contained in:
andy committed 2026-09-14 17:15:52 +08:00
1 parent 9625110f44
commit 8d55e930a2
3 files changed
+315 -25

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
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(), '已连接');
});
+55 -25
View File
@@ -17,6 +17,7 @@ const HISTORY_TASK_PAGE_SIZE = 24;
const OPERATIONS_DASHBOARD_PAGE_SIZE = 20;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const AUTH_API_TIMEOUT_MS = 10_000;
const BRIDGE_PING_TIMEOUT_MS = 10_000;
const RESULT_API_TIMEOUT_MS = 20_000;
const TASK_DELETE_API_TIMEOUT_MS = 60_000;
const LOCAL_TASK_OVERLAY_TTL_MS = 10 * 60_000;
@@ -70,6 +71,7 @@ const taskInputHistoryRequests = new Map();
const localTaskOverlayStore = new Map();
let pollInProgress = false;
let backgroundRefreshInProgress = false;
let bridgePingInFlight = null;
let taskListMeta = {
total: 0,
offset: 0,
@@ -4706,7 +4708,9 @@ window.addEventListener('message', (event) => {
if (message.source !== EXTENSION_SOURCE) return;
if (!canUseTaskDataPlane()) return;
if (message.type === 'BRIDGE_READY') {
applyBridgePayload(message.payload || { ok: true });
// The announcement has no expected ERP account. Verify this session rather
// than overwriting an account-bound result with its unbound snapshot.
pingBridge().catch(() => {});
return;
}
if (message.type === 'TASK_RESULT_CHANGED') {
@@ -5062,23 +5066,40 @@ async function confirmAndSubmitToErpPlugin(task) {
async function pingBridge() {
if (!canUseTaskDataPlane()) return false;
const user = authUser;
const connectionId = browserConnectionId;
if (bridgePingInFlight?.user === user && bridgePingInFlight.connectionId === connectionId) {
return bridgePingInFlight.promise;
}
const probe = { user, connectionId, promise: null };
probe.promise = checkBridgeConnection(user, connectionId).finally(() => {
if (bridgePingInFlight === probe) bridgePingInFlight = null;
});
bridgePingInFlight = probe;
return probe.promise;
}
async function checkBridgeConnection(user, connectionId) {
const isCurrentSession = () => authUser === user && browserConnectionId === connectionId && canUseTaskDataPlane();
try {
const result = await sendToExtension('PING', {
expected_erp_account: authUser?.erp_account || ''
}, 1200);
expected_erp_account: user.erp_account || ''
}, BRIDGE_PING_TIMEOUT_MS);
if (!isCurrentSession()) return false;
applyBridgePayload(result);
if (authUser && result?.ok) {
if (result?.ok) {
try {
const heartbeat = await apiRequest('/api/connections/heartbeat', {
method: 'POST',
body: {
connection_id: browserConnectionId,
connection_id: connectionId,
extension_version: result.version || '',
erp_account: authUser.erp_account || '',
erp_account: user.erp_account || '',
erp_account_matched: result.erp_session?.account_matched === true,
metadata: { bridge_installed_at: result.bridge_installed_at || '' }
}
});
if (!isCurrentSession()) return false;
if (heartbeat.execution_ready !== true) {
erpSessionReady = false;
latestBridgeStatus = {
@@ -5094,6 +5115,7 @@ async function pingBridge() {
return false;
}
} catch (heartbeatError) {
if (!isCurrentSession()) return false;
erpSessionReady = false;
latestBridgeStatus = {
...latestBridgeStatus,
@@ -5107,6 +5129,7 @@ async function pingBridge() {
}
return bridgeConnected && erpSessionReady;
} catch (error) {
if (!isCurrentSession()) return false;
bridgeConnected = false;
erpAutomationEnabled = false;
extensionCompatible = false;
@@ -5181,11 +5204,17 @@ async function createTask() {
}
async function refreshBackgroundState() {
if (!authUser || backgroundRefreshInProgress) return;
if (!authUser) return;
// Worker liveness must not wait for task-list/database synchronization.
// pingBridge coalesces timer, focus and manual checks for this session.
const bridgeRefresh = canUseTaskDataPlane() ? pingBridge() : Promise.resolve(false);
if (backgroundRefreshInProgress) {
await bridgeRefresh;
return;
}
backgroundRefreshInProgress = true;
try {
const operations = [pingAi()];
if (canUseTaskDataPlane()) operations.push(pingBridge());
const operations = [pingAi(), bridgeRefresh];
if (isAdministrator()) operations.push(syncAutomationSettings({ background: true }));
if (IS_CHANNELS_PAGE && isAdministrator()) {
operations.push(syncChannels(), syncLeaderSummaryWebhookStatus());
@@ -6295,26 +6324,27 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!button) return;
void copyTaskLifecycle(button);
});
// Register once per page, even when initial session restoration fails.
// refreshBackgroundState remains inert while logged out; later login uses
// these same listeners/timer without requiring a full page reload.
setInterval(() => {
refreshBackgroundState().catch(() => {});
}, 30_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
stopPolling();
return;
}
refreshBackgroundState().catch(() => {});
if (hasPollableRuntimeTasks()) startPolling();
});
window.addEventListener('focus', () => {
refreshBackgroundState().catch(() => {});
});
if (await initializeSession()) {
if (IS_TASK_PAGE && canUseTaskDataPlane()) renderTaskCards();
pingAi().catch(() => {});
if (canUseTaskDataPlane()) pingBridge().catch(() => {});
// The control plane caches this health probe; keep the browser refresh
// interval conservative so status checks cannot pressure the Agent API.
setInterval(() => {
refreshBackgroundState().catch(() => {});
}, 30_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
stopPolling();
return;
}
refreshBackgroundState().catch(() => {});
if (hasPollableRuntimeTasks()) startPolling();
});
window.addEventListener('focus', () => {
refreshBackgroundState().catch(() => {});
});
if (IS_TASK_PAGE && canUseTaskDataPlane() && currentTaskId) renderTaskDetail();
if (IS_TASK_PAGE && canUseTaskDataPlane() && hasPollableRuntimeTasks()) startPolling();
}