165 lines
8.7 KiB
JavaScript
165 lines
8.7 KiB
JavaScript
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');
|
|
});
|