Files
LWLT-AIBOT/LianSyn-platform/app-dispatch-recovery.test.mjs
2026-09-15 11:34:59 +08:00

144 lines
7.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8');
function functionSource(name) {
const start = source.search(new RegExp(`(?:async )?function ${name}\\(`));
assert.ok(start >= 0, name);
const rest = source.slice(start);
const next = rest.slice(1).search(/\n(?:async )?function /);
return next < 0 ? rest : rest.slice(0, next + 1);
}
const helperNames = ['isDispatchResultPending', 'isTaskPollable', 'pollTaskResult', 'persistExtensionTaskResult', 'reconcileTaskReceipt', 'handoffTaskToExtension'];
function fixture({ result, state = 'reconciliation_pending', owner = true, acceptDelay = 0, resultError = false } = {}) {
const task = { task_id: 'task-a', confirmed_at: 'fixture', status: state, handoff_status: state,
result: { execution_id: 'execution-a', execution_phase: 'dispatch_uncertain', stage: 'dispatch', status: 'reconciliation_pending' } };
const calls = [];
const timers = [];
const context = vm.createContext({ task, calls, Map, Set, Promise, Date,
taskServerStatus: t => t?.status,
findTaskById: () => task,
taskAssignedToCurrentAccount: () => owner,
extensionTaskBelongsToCurrentAccount: () => owner,
isAutomaticTask: () => true,
updateLocalTask: (_id, patch) => Object.assign(task, patch),
upsertLocalTask: t => Object.assign(task, t),
sessionStorage: { setItem() {} },
apiRequest: async (path, options) => {
calls.push({ type: 'api', path, body: options.body });
if (path.endsWith('/claim')) return { claimed: true, execution_id: 'execution-a', task: { ...task, status: 'queued' } };
if (path.endsWith('/result')) {
if (resultError) throw new Error('fixture result rejected');
const r = options.body.result;
const status = r.status === 'execution_uncertain' ? 'reconciliation_pending' : r.status;
return { task: { ...task, status, result: r, handoff_status: status } };
}
throw new Error(`unexpected API ${path}`);
},
sendToExtension: async (type, payload, timeout) => {
calls.push({ type, payload, timeout });
if (type === 'GET_TASK_RESULT') return { result };
if (type === 'CREATE_TASK') {
assert.ok(timeout >= 10_000);
if (acceptDelay > timeout) throw new Error('插件桥接未响应');
return { task_id: 'task-a', execution_id: 'execution-a', accepted: true };
}
throw new Error(`unexpected bridge action ${type}`);
},
canReconcileTask: () => true,
pingBridge: async () => true,
renderTaskDetail: () => {}, startPolling: () => {}, syncRemoteTasks: async () => {},
setTaskState: label => calls.push({ type: 'label', label }),
setTimeout: callback => timers.push(callback), clearTimeout: () => {},
});
const timeoutConstant = source.match(/const BRIDGE_TASK_ACCEPT_TIMEOUT_MS = [^;]+;/)?.[0];
assert.ok(timeoutConstant, 'acceptance timeout must be explicit');
vm.runInContext(`${timeoutConstant}
const MANUAL_RECONCILIATION_STATUSES = new Set(['reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain']);
const TERMINAL_TASK_STATUSES = new Set(['completed', 'blocked', 'failed', 'cancelled', 'dry_run']);
const taskReconciliationStates = new Map();
const browserConnectionId = 'connection-a';
const REQUIRED_EXTENSION_VERSION = 'fixture';
let currentTaskId = '';
const bridgeConnected = true, extensionCompatible = true, erpHostAccessReady = true, erpSessionReady = true, erpAutomationEnabled = true;
const latestBridgeStatus = {};
${helperNames.map(functionSource).join('\n')}
queueExtensionTaskResult = persistExtensionTaskResult;
`, context);
return { task, calls, context, run: expr => vm.runInContext(expr, context) };
}
test('five-second plugin acceptance no longer triggers the old four-second false timeout', async () => {
const f = fixture({ state: 'confirmed', acceptDelay: 5000 });
f.task.handoff_status = 'awaiting_handoff';
await f.run('handoffTaskToExtension(task)');
assert.equal(f.task.status, 'running');
assert.equal(f.calls.filter(c => c.type === 'CREATE_TASK').length, 1);
assert.equal(f.calls.filter(c => c.path?.endsWith('/result')).length, 0);
});
test('a genuine acceptance timeout records uncertainty and never dispatches twice', async () => {
const f = fixture({ state: 'confirmed', acceptDelay: 15000 });
f.task.handoff_status = 'awaiting_handoff';
await assert.rejects(f.run('handoffTaskToExtension(task)'), /插件桥接未响应/);
assert.equal(f.task.status, 'reconciliation_pending');
await assert.rejects(f.run('handoffTaskToExtension(task)'), /不会再次提交/);
assert.equal(f.calls.filter(c => c.type === 'CREATE_TASK').length, 1);
});
test('dispatch uncertainty reads stored late blocked results with no ERP action or new claim', async () => {
const f = fixture({ result: { execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', no_erp_write: true } });
assert.equal(f.run('isTaskPollable(task)'), true);
await f.run('pollTaskResult(task.task_id)');
assert.equal(f.task.status, 'blocked');
assert.deepEqual(f.calls.map(c => c.type), ['GET_TASK_RESULT', 'api']);
assert.equal(f.calls[1].body.execution_id, 'execution-a');
assert.equal(f.calls[1].body.connection_id, 'connection-a');
});
test('late progress does not replace dispatch uncertainty or post another API result', async () => {
for (const status of ['accepted', 'running', 'execution_uncertain']) {
const f = fixture({ result: { execution_id: 'execution-a', status } });
await f.run('pollTaskResult(task.task_id)');
assert.equal(f.task.status, 'reconciliation_pending');
assert.equal(f.calls.length, 1);
}
});
test('a rejected stored result preserves the platform uncertainty state', async () => {
const f = fixture({ resultError: true, result: { execution_id: 'another', status: 'blocked', stage: 'erp_resolution' } });
await f.run('pollTaskResult(task.task_id)');
assert.equal(f.task.status, 'reconciliation_pending');
assert.equal(f.task.result.execution_id, 'execution-a');
assert.equal(f.task.result.execution_phase, 'dispatch_uncertain');
assert.match(f.task.message, /持久化失败/);
});
test('post-write uncertainty keeps its existing manual reconciliation boundary', async () => {
const f = fixture(); f.task.result.execution_phase = 'submitted';
assert.equal(f.run('isTaskPollable(task)'), false);
await f.run('pollTaskResult(task.task_id)');
assert.equal(f.calls.length, 0);
});
test('maintenance-expired dispatch tasks can manually sync local plugin results without ERP reconciliation', async () => {
const f = fixture({ state: 'failed', result: { execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', no_erp_write: true } });
f.task.result.stage = 'reconciliation_timeout';
assert.equal(f.run('isTaskPollable(task)'), false, 'expired tasks do not poll forever');
await f.run('reconcileTaskReceipt(task)');
assert.equal(f.task.status, 'blocked');
assert.equal(f.calls.filter(c => c.type === 'GET_TASK_RESULT').length, 1);
assert.ok(!f.calls.some(c => c.type === 'RECONCILE_TASK' || c.type === 'CREATE_TASK' || c.path?.endsWith('/claim')));
});
test('other-account tasks cannot read, persist or dispatch plugin tasks', async () => {
const f = fixture({ owner: false });
await f.run('pollTaskResult(task.task_id)');
await f.run("persistExtensionTaskResult(task.task_id, {status: 'blocked'})");
await assert.rejects(f.run('reconcileTaskReceipt(task)'), /不能执行回查/);
assert.equal(f.calls.length, 0);
});