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); });