Files
LWLT-AIBOT/tools/erp-resolution-navigation.test.mjs
2026-09-15 14:24:20 +08:00

239 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import test from 'node:test';
import vm from 'node:vm';
const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
const timing = createRequire(import.meta.url)('../chrome-extension/ltjt-order-assistant/operation-timing.js');
const slice = (start, end) => {
const first = source.indexOf(start);
const last = source.indexOf(end, first);
assert.ok(first >= 0 && last > first);
return source.slice(first, last);
};
const removed = () => new Error('Frame with ID 0 was removed.');
const operation = { action: 'passenger_list_import', data: {} };
const ready = () => [{ frameId: 0, result: { status: 'erp_resolution_ready', resolved_operation: operation } }];
function harness(options = {}) {
let clock = Date.parse('2026-09-15T03:51:14.000Z');
const state = {
calls: [], probes: 0, injections: 0, lookups: 0, details: [], saved: {}, published: [],
record: { execution_id: 'execution-fixture', state: 'running' },
tab: { id: 7, status: 'complete', url: 'https://erp.example.invalid/System/Mainlt.asp' },
recovery: {
recovery_status: 'success', recovery_reason: 'erp_page_access_denied', recovery_trigger: 'session_status',
recovery_attempt_at: '2026-09-15T03:51:13.000Z', recovery_reload_requested_at: '2026-09-15T03:51:13.100Z',
recovery_completed_at: '2026-09-15T03:51:14.000Z', recovery_tab_id: 7,
last_tab_url: 'SECRET_URL', last_error: 'SECRET_ERROR', passenger: 'SECRET_PERSON'
}
};
const context = {
Error, Promise, setTimeout, clearTimeout,
Date: class extends Date { static now() { return clock; } },
performance: { now: () => clock }, nowIso: () => new Date(clock).toISOString(),
throwIfTaskCancelled: () => { if (state.cancelled) throw Object.assign(new Error('cancelled'), { code: 'task_cancelled' }); },
delay: async (ms) => { clock += ms; options.onDelay?.(state); },
getExecutionRecord: async () => state.record ? { ...state.record } : null,
readErpKeepaliveState: async () => ({ ...state.recovery }),
isApprovedErpPageUrl: (url) => String(url).startsWith('https://erp.example.invalid/'),
canReusePageActionScripts: async () => false,
pageActionFiles: () => ['operation-plans.js', 'inpage.js'],
timingActionCode: (action) => action === 'resolveLifecycleOperation' ? 'resolve_lifecycle_operation' : 'other',
queueOperationTimingDetails: (_task, details) => state.details.push(...details),
queuePageActionResultTiming() {},
executeErpScript: async (_tab, spec) => {
if (spec.files) {
state.injections += 1;
if (options.failInjection && state.injections === 1) throw removed();
return [{ frameId: 0 }];
}
const action = spec.args[0];
state.calls.push(action);
if (action === 'resolveLifecycleOperation') {
state.lookups += 1;
return options.lookup ? options.lookup(state) : ready();
}
if (action === 'prepareLifecycleOperation') {
if (options.failRoute) throw removed();
return [{ frameId: 0, result: { status: 'lifecycle_route_ready' } }];
}
if (action === 'liveSubmitLifecycleOperation') throw removed();
throw new Error(`unexpected action ${action}`);
},
chrome: {
tabs: { get: async () => { if (options.closed) throw new Error('No tab with id: 7.'); return { ...state.tab }; } },
scripting: { executeScript: async (spec) => {
state.probes += 1;
assert.equal(spec.target.frameIds[0], 0);
assert.equal(spec.files, undefined);
assert.match(spec.func.toString(), /document.readyState/);
if (options.probeError) throw options.probeError();
const documentId = options.documents ? options.documents(state.probes) : 'document-new';
return [{ frameId: 0, documentId, result: { ready: true, url: state.tab.url } }];
} },
storage: { local: { set: async (patch) => Object.assign(state.saved, patch) } }
},
operationPlans: { normalizeOperation: (value) => value, publicPlan: (value) => value, validateOperation: () => ({ ok: true }) },
summaryFromOperation: () => ({}),
findOrOpenErpTab: async () => { if (options.failFind) throw removed(); return state.tab; },
setResult: async (_task, value) => {
const advanced = timing.advance(state.timingState, {
now_ms: clock, stage: value.stage, status: value.status, details: state.details.splice(0)
});
state.timingState = advanced.state;
state.timing = advanced.snapshot;
},
publish: async (_task, _operation, stage, report, extra) => {
state.published.push({ stage, report, ...extra });
await context.setResult(_task, { stage, ...extra });
},
LIFECYCLE_EDIT_DIALOG_ACTIONS: new Set(),
cacheResolvedExecutionOperation: async () => {},
preflightLifecycleAfterRoute: async () => {
if (options.failPreflight) throw removed();
return { status: 'lifecycle_preflight_ready', no_erp_write: true, write_attempted: false };
},
lifecyclePreflightBusinessFailure: () => null,
transitionCurrentExecution: async (_task, next) => { state.record.state = next; },
lifecycleResult: { isVerifiedResult: () => false }
};
vm.createContext(context);
vm.runInContext([
slice('async function withDeadline', 'function taskRootId'),
slice('const LIFECYCLE_EVIDENCE_DEFAULTS', 'function withStorageMutation'),
slice('function pageActionError', 'function pickResult'),
slice('function withoutResolvedOperation', 'function browserExecutionAction')
].join('\n'), context);
return { state, resolve: () => context.resolveOperationInErp(7, 'task-fixture', operation),
execute: () => context.executeLifecycleOperation('task-fixture', operation, { ok: true }),
elapsed: () => clock - Date.parse('2026-09-15T03:51:14.000Z') };
}
test('normal lookup does not wait; business no-match and ambiguous results never retry', async () => {
for (const count of [null, 0, 2]) {
const h = harness({ lookup: () => count === null ? ready() : [{ result: {
status: 'erp_resolution_blocked', blockers: [`erp_resolution_candidate_count:${count}`]
} }] });
const result = await h.resolve();
assert.equal(result.ok, count === null);
assert.equal(h.state.lookups, 1);
assert.equal(h.state.probes, 0);
}
});
test('removed query frame waits for a stable current document then reinjects and queries once', async () => {
const h = harness({ lookup: (state) => { if (state.lookups === 1) throw removed(); return ready(); },
documents: (count) => count === 1 ? 'old' : 'new' });
const result = await h.resolve();
assert.equal(result.ok, true);
assert.equal(h.state.injections, 2);
assert.equal(h.state.lookups, 2);
assert.equal(h.state.probes, 3);
assert.equal(h.elapsed(), 500);
const diagnostic = result.report.preflight.navigation_retry;
assert.equal(diagnostic.attempts, 2);
assert.equal(diagnostic.failures[0].step, 'action_roundtrip.resolve_lifecycle_operation');
assert.equal(diagnostic.failures[0].recovery.same_tab, true);
h.state.recovery.recovery_status = 'running';
assert.equal(diagnostic.failures[0].recovery.recovery_status, 'success');
assert.doesNotMatch(JSON.stringify(diagnostic), /SECRET_|erp.example|document-new/);
});
test('frame removal during injection also safely recovers and identifies injection as failed step', async () => {
const h = harness({ failInjection: true });
const result = await h.resolve();
assert.equal(result.ok, true);
assert.equal(h.state.injections, 2);
assert.equal(h.state.lookups, 1);
assert.equal(result.report.preflight.navigation_retry.failures[0].step, 'script_injection');
});
test('repeated frame removal stops after two lookups with actual resolution failure stage and no-write evidence', async () => {
const h = harness({ lookup: () => { throw removed(); } });
await h.execute();
assert.equal(h.state.lookups, 2);
const result = h.state.published.at(-1);
assert.equal(result.stage, 'erp_resolution');
assert.equal(result.failure_stage, 'erp_resolution');
assert.equal(result.error_code, 'erp_resolution_page_changed');
assert.equal(result.report.status, 'erp_resolution_blocked');
assert.equal(result.report.preflight.stage, 'erp_readonly_resolution');
assert.equal(result.report.preflight.write_attempted, false);
assert.equal(result.report.no_erp_write, true);
assert.equal(result.report.native_request, null);
assert.equal(result.report.server_response, null);
assert.equal(result.report.requery, null);
assert.equal(result.report.side_effects.length, 4);
assert.equal(h.state.timing.stages.at(-1).stage, 'erp_resolution');
assert.equal(h.state.timing.stages.at(-1).status, 'blocked');
assert.equal(h.state.saved.lastLifecyclePreflight, undefined);
assert.equal(h.state.calls.includes('liveSubmitLifecycleOperation'), false);
});
test('moving/loading/missing-identity documents cannot pass readiness and the wait is bounded', async () => {
for (const mode of ['moving', 'loading', 'no-document-id', 'frame-removed']) {
const h = harness({ lookup: () => { throw removed(); },
documents: (count) => mode === 'no-document-id' ? undefined : `new-${count}`,
probeError: mode === 'frame-removed' ? removed : null });
if (mode === 'loading') h.state.tab.status = 'loading';
const result = await h.resolve();
assert.equal(result.ok, false);
assert.equal(h.state.lookups, 1);
assert.equal(h.elapsed(), 8000);
assert.match(result.report.blockers[0], /erp_resolution_document_not_stable/);
}
});
test('permanent errors, closed tabs, and disallowed navigation do not rerun queries', async () => {
for (const mode of ['permission', 'closed', 'unapproved', 'pending-unapproved', 'probe-permission']) {
const h = harness({ closed: mode === 'closed',
lookup: () => { throw mode === 'permission' ? new Error('Cannot access contents of the page.') : removed(); },
probeError: mode === 'probe-permission' ? () => new Error('permission denied') : null });
if (mode === 'unapproved') h.state.tab.url = 'https://elsewhere.invalid/';
if (mode === 'pending-unapproved') h.state.tab.pendingUrl = 'https://elsewhere.invalid/';
assert.equal((await h.resolve()).ok, false);
assert.equal(h.state.lookups, 1);
assert.equal(h.elapsed(), 0);
}
});
test('retry requires the same running execution without a prior write boundary; cancellation propagates', async () => {
for (const mode of ['missing', 'write-started', 'old-write', 'replaced', 'write-during-wait', 'cancelled']) {
const h = harness({ lookup: () => { throw removed(); }, onDelay: (state) => {
if (mode === 'replaced') state.record.execution_id = 'other';
if (mode === 'write-during-wait') state.record.state = 'write_started';
if (mode === 'cancelled') state.cancelled = true;
} });
if (mode === 'missing') h.state.record = null;
if (mode === 'write-started') h.state.record.state = 'write_started';
if (mode === 'old-write') h.state.record.write_started_at = '2026-09-15T03:50:00Z';
if (mode === 'cancelled') await assert.rejects(h.resolve(), (error) => error.code === 'task_cancelled');
else assert.match((await h.resolve()).report.blockers[0], /erp_resolution_retry_not_safe/);
assert.equal(h.state.lookups, 1);
}
});
test('exceptions before route, during route and during preflight retain the actual failure phase', async () => {
for (const [option, stage] of [['failFind', 'erp_resolution'], ['failRoute', 'lifecycle_route_preparation'],
['failPreflight', 'lifecycle_preflight']]) {
const h = harness({ [option]: true });
await h.execute();
assert.equal(h.state.published.at(-1).stage, stage);
assert.equal(h.state.published.at(-1).no_erp_write, true);
assert.equal(h.state.probes, 0);
assert.equal(h.state.calls.includes('liveSubmitLifecycleOperation'), false);
}
});
test('frame removal at live submit is uncertain and never retried', async () => {
const h = harness();
const result = await h.execute();
assert.equal(result.status, 'execution_uncertain');
assert.equal(result.report.no_erp_write, false);
assert.equal(result.report.write_attempted, true);
assert.equal(h.state.calls.filter((action) => action === 'liveSubmitLifecycleOperation').length, 1);
assert.equal(h.state.probes, 0);
});