import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8') .replace('assistant.preflightLifecycleOperation = preflightLifecycleOperation;', 'assistant.testWaitForStable = waitForStable; assistant.preflightLifecycleOperation = preflightLifecycleOperation;'); const background = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); function fixture({ delays = [], onTick = () => {} } = {}) { let now = 0; let ticks = 0; const control = { name: 'jine1', id: 'jine1', type: 'text', value: 'private-value', isConnected: true }; const document = { querySelectorAll: () => [], readyState: 'complete' }; const window = { document, LTJTOrderAssistant: {}, jQuery: { active: 0 }, performance: { now: () => now }, setTimeout(callback, ms) { now += delays[ticks] ?? ms; onTick({ now, ticks: ++ticks, control, form, window }); callback(); } }; window.parent = window; document.defaultView = window; const form = { elements: [control], isConnected: true, ownerDocument: document }; vm.runInNewContext(source, { window, Date: class extends Date { static now() { return now; } }, URL, URLSearchParams }); const diagnostic = {}; return { control, form, window, diagnostic, now: () => now, run: () => window.LTJTOrderAssistant.testWaitForStable(form, ['jine1', 'optional1'], 6000, diagnostic) }; } test('ordinary linkage requires multiple observations over 1.5 seconds and redacts values', async () => { const f = fixture(); assert.equal(await f.run(), true); assert.equal(f.now(), 1500); assert.ok(f.diagnostic.sample_count >= 3); assert.equal(f.diagnostic.ready, true); assert.equal(f.diagnostic.reason, 'stable'); assert.equal(JSON.stringify(f.diagnostic).includes('private-value'), false); }); test('one delayed callback restarts observation instead of failing or counting the blind interval', async () => { const f = fixture({ delays: [6000] }); assert.equal(await f.run(), true); assert.equal(f.now(), 7500); assert.equal(f.diagnostic.delayed_sample_count, 1); assert.equal(f.diagnostic.max_sample_gap_ms, 6000); assert.equal(f.diagnostic.stable_ms, 1500); }); test('one-second sampling still proves stability; persistent long gaps fail boundedly', async () => { const slower = fixture({ delays: [1000, 1000] }); assert.equal(await slower.run(), true); assert.equal(slower.now(), 2000); const paused = fixture({ delays: Array(6).fill(6000) }); assert.equal(await paused.run(), false); assert.equal(paused.now(), 30000); assert.equal(paused.diagnostic.reason, 'sampling_delayed'); assert.equal(paused.diagnostic.stable_ms, 0); }); test('continuous value changes fail and report field names rather than their values', async () => { const f = fixture({ onTick: ({ control, ticks }) => { control.value = `secret-${ticks}`; } }); assert.equal(await f.run(), false); assert.equal(f.now(), 6000); assert.equal(f.diagnostic.reason, 'fields_changing'); assert.deepEqual([...f.diagnostic.changed_fields], ['jine1']); assert.equal(JSON.stringify(f.diagnostic).includes('secret-'), false); }); test('the last allowed sample is checked; a late change restarts stability', async () => { const f = fixture({ onTick: ({ now, control }) => { if (now <= 4500) control.value = String(now); } }); assert.equal(await f.run(), true); assert.equal(f.now(), 6000); const late = fixture({ delays: [6000], onTick: ({ now, control }) => { if (now === 7250) control.value = 'late'; } }); assert.equal(await late.run(), true); assert.equal(late.now(), 8750); }); test('detached form, missing/replaced controls and pending native Ajax cannot pass', async () => { for (const mutate of [ ({ form }) => { form.isConnected = false; }, ({ form }) => { form.elements = []; }, ({ form, control }) => { form.elements = [{ ...control }]; }, ({ control }) => { control.isConnected = false; } ]) { const f = fixture({ onTick: mutate }); assert.equal(await f.run(), false); assert.match(f.diagnostic.reason, /form_detached|control_unavailable/); } const busy = fixture(); busy.window.jQuery.active = 1; assert.equal(await busy.run(), false); assert.equal(busy.diagnostic.reason, 'native_busy'); const finishes = fixture({ onTick: ({ now, window }) => { if (now >= 1000) window.jQuery.active = 0; } }); finishes.window.jQuery.active = 1; assert.equal(await finishes.run(), true); assert.equal(finishes.now(), 2500); const late = fixture({ onTick: ({ now, window }) => { if (now >= 5500) window.jQuery.active = 0; } }); late.window.jQuery.active = 1; assert.equal(await late.run(), false); assert.equal(late.diagnostic.reason, 'native_busy', 'late Ajax completion is not evidence of oscillating field values'); assert.equal(late.diagnostic.native_busy, false); assert.ok(late.diagnostic.native_busy_sample_count > 0); }); test('background reports the observed failure reason without classifying all failures as changing fields', () => { const start = background.indexOf('function arrangementLinkageFailureMessage('); const end = background.indexOf('\nfunction lifecycleLiveBusinessFailure(', start); const message = vm.runInNewContext(`${background.slice(start, end)}; arrangementLinkageFailureMessage;`); for (const [reason, expected] of [['sampling_delayed', '检测持续延迟'], ['fields_changing', '联动字段在观察期间未稳定'], ['native_busy', '联动请求'], ['form_detached', '关闭或刷新'], ['control_unavailable', '控件']]) { const result = message({ blockers: ['arrangement_create_linked_fields_not_stable'], preflight: { arrangement_linkage: { reason } } }); assert.ok(result.includes(expected)); assert.ok(result.includes('未提交 ERP 保存')); } assert.equal(message({ blockers: ['other_error'] }), ''); assert.equal(message({ blockers: ['arrangement_create_linked_fields_not_stable'] }), ''); assert.match(background, /failure_message: linkageFailureMessage/); });