239 lines
12 KiB
JavaScript
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);
|
|
});
|