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('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const testSource = source.replace(' resolveNativeListSearchValues,', ' testSearch: searchMainPage,\n resolveNativeListSearchValues,'); function fixture({ framed = true, tick = () => {} } = {}) { let now = 0; const calls = []; const state = { current: null, framePresent: framed, inaccessible: false }; function list({ path = '/System/Business/orders.asp', complete = true, loader = true, button = true, disabled = false } = {}) { const listeners = new Map(); const fields = new Map(); const search = { isConnected: true, disabled }; for (const name of ['S_chufariqi', 'S_chufarizhi', 'S_tuanxuhao', 'S_kehuming', 'S_chanpinming', 'S_youkexinxi', 'S_lingdui', 'S_zhuangtai']) fields.set(name, { value: '' }); const doc = { location: { pathname: path, href: `https://erp.invalid${path}` }, readyState: complete ? 'complete' : 'loading', querySelector(selector) { if (selector.startsWith('#SearchButton')) return button ? search : null; return fields.get(selector.match(/^#([^,]+)/)?.[1]) || null; }, querySelectorAll: () => [], fields, search }; const events = { on: (name, callback) => listeners.set(name.split('.')[0], callback), off: () => listeners.clear() }; const jq = () => events; jq.ajax = () => {}; jq.active = 0; doc.defaultView = { document: doc, jQuery: jq }; if (loader) doc.defaultView.AjaxLoadData = () => { calls.push({ document: doc, identifier: fields.get('S_tuanxuhao').value }); const xhr = { status: 200 }; const data = new URLSearchParams({ Act: 'PrintGridList', ...Object.fromEntries([...fields].map(([name, field]) => [name, field.value])) }); listeners.get('ajaxSend')?.({}, xhr, { url: '/System/Dat/order.asp', data: data.toString() }); listeners.get('ajaxComplete')?.({}, xhr); }; return doc; } const initial = list(); state.current = initial; const frame = { get contentDocument() { if (state.inaccessible) throw new Error('fixture access denied'); return state.current; } }; const root = framed ? { location: { pathname: '/System/Mainlt.asp' }, getElementById: () => state.framePresent ? frame : null, querySelector: () => null } : initial; if (!framed) { root.getElementById = () => null; } const window = { document: root, location: root.location }; vm.runInNewContext(testSource, { window, document: root, location: root.location, URL, URLSearchParams, Date: class extends Date { static now() { return now; } }, performance: { now: () => now }, setTimeout(callback, delay) { now += delay; tick({ now, state, initial }); callback(); } }); return { api: window.LTJTOrderAssistant, list, initial, root, state, calls, elapsed: () => now, run: () => window.LTJTOrderAssistant.testSearch(initial, { S_tuanxuhao: 'TEST-LIST-001', S_zhuangtai: '' }) }; } test('ready native list searches once and retains exact request evidence', async () => { for (const framed of [true, false]) { const f = fixture({ framed }); const result = await f.run(); assert.equal(result, f.initial); assert.equal(f.calls.length, 1); assert.equal(f.calls[0].identifier, 'TEST-LIST-001'); assert.equal(result.__ltjtNativeListSearchEvidence.completed, true); } }); test('a loading same-path Document replacement is reacquired before searching', async () => { let replacement; const f = fixture({ tick({ now, state }) { if (now >= 500) state.current = replacement; } }); f.initial.readyState = 'loading'; replacement = f.list(); const result = await f.run(); assert.equal(result, replacement); assert.equal(f.calls.length, 1); assert.equal(f.calls[0].document, replacement); assert.equal(f.initial.fields.get('S_tuanxuhao').value, '', 'detached document must not be filled'); assert.equal(result.__ltjtNativeListSearchEvidence.readiness.document_replacements, 1); }); test('replaced same-path document can finish loading later without using its predecessor', async () => { let replacement; const f = fixture({ tick({ now, state }) { if (now >= 100) state.current = replacement; if (now >= 1200) replacement.readyState = 'complete'; } }); f.initial.readyState = 'loading'; replacement = f.list({ complete: false }); assert.equal(await f.run(), replacement); assert.equal(f.elapsed(), 1200); }); for (const [name, change, missing] of [ ['unfinished page', f => { f.initial.readyState = 'loading'; }, 'document_complete'], ['missing loader', f => { delete f.initial.defaultView.AjaxLoadData; }, 'loader_ready'], ['disabled button', f => { f.initial.search.disabled = true; }, 'search_button_ready'], ['detached button', f => { f.initial.search.isConnected = false; }, 'search_button_ready'], ['wrong page', f => { f.state.current = f.list({ path: '/System/login.asp' }); }, 'path_matched'], ['missing document', f => { f.state.current = null; }, 'document_present'], ['removed frame', f => { f.state.framePresent = false; }, 'document_present'], ['inaccessible frame', f => { f.state.inaccessible = true; }, 'document_accessible'] ]) test(`native readiness stops without searching: ${name}`, async () => { const f = fixture(); change(f); await assert.rejects(f.run(), error => { assert.match(error.message, /native_list_search_not_ready/); assert.ok(error.message.includes(missing)); assert.equal(error.nativeListReadiness[missing], false); assert.ok(!JSON.stringify(error.nativeListReadiness).includes('TEST-LIST')); return true; }); assert.equal(f.calls.length, 0); assert.equal(f.elapsed(), 15000); }); test('document replacement during prior Ajax wait fails closed without searching either page', async () => { const f = fixture({ tick({ state, initial }) { state.current = f.list(); initial.defaultView.jQuery.active = 0; } }); f.initial.defaultView.jQuery.active = 1; await assert.rejects(f.run(), /native_list_search_document_changed/); assert.equal(f.calls.length, 0); }); test('missing button is diagnosed separately from a ready document and loader', async () => { const f = fixture(); f.state.current = f.list({ button: false }); await assert.rejects(f.run(), error => error.nativeListReadiness.search_button_ready === false && error.nativeListReadiness.document_complete === true && error.nativeListReadiness.loader_ready === true); assert.equal(f.calls.length, 0); }); test('a page change during the native search is rejected instead of using stale result rows', async () => { const f = fixture(); const load = f.initial.defaultView.AjaxLoadData; f.initial.defaultView.AjaxLoadData = () => { load(); f.state.current = f.list(); }; await assert.rejects(f.run(), /native_list_search_document_changed/); assert.equal(f.calls.length, 1, 'only the original read-only search is issued; no retry'); assert.equal(f.initial.__ltjtNativeListSearchEvidence, undefined); }); test('the real resolution failure carries explicit no-write proof and value-free readiness details', async () => { const f = fixture(); delete f.initial.defaultView.AjaxLoadData; const report = await f.api.resolveLifecycleOperation({ action: 'arrangement_hotel', data: { existing_refs: { identifier: 'TEST-LIST-001' } } }); assert.equal(report.status, 'erp_resolution_blocked'); assert.equal(report.no_erp_write, true); assert.equal(report.write_attempted, false); assert.equal(report.native_request, null); assert.equal(report.preflight.stage, 'erp_readonly_resolution'); assert.equal(report.preflight.native_list_readiness.loader_ready, false); assert.equal(f.calls.length, 0); }); test('the worker describes readiness failures accurately while preserving not-found errors', () => { const worker = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const fn = worker.slice(worker.indexOf('function erpResolutionFailure('), worker.indexOf('function lifecycleRouteFailure(')); const context = vm.createContext({}); vm.runInContext(fn, context); for (const cause of ['native_list_search_not_ready', 'native_list_search_not_ready:loader_ready', 'native_list_search_document_changed']) { const failure = context.erpResolutionFailure({}, { blockers: [`erp_resolution_navigation_failed:${cause}`] }); assert.equal(failure.errorCode, 'erp_list_search_not_ready'); assert.match(failure.message, /未写入 ERP/); assert.doesNotMatch(failure.message, /确认 ERP 已登录/); } assert.equal(context.erpResolutionFailure({}, { blockers: ['erp_resolution_candidate_count:0'] }).errorCode, 'erp_target_not_found'); });