excel唯一逻辑修复
This commit is contained in:
1 parent
ede1d47c47
commit
af85ea22ec
24 files changed
+784
-34
No files matched your search
@@ -0,0 +1,238 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user