144 lines
7.6 KiB
JavaScript
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);
|
|
});
|