import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import vm from 'node:vm'; const require = createRequire(import.meta.url); const timing = require('../chrome-extension/ltjt-order-assistant/operation-timing.js'); test('operation timing closes the previous stage and attributes buffered page work to it', () => { let state = timing.createState({ now_ms: 1_000, started_at_ms: 1_000, stage: 'open_order_form', status: 'running' }); const advanced = timing.advance(state, { now_ms: 2_500, stage: 'wait_order_form', status: 'running', details: [ { step: 'script_injection', status: 'completed', duration_ms: 300, counters: { call_count: 1 } }, { step: 'action_runtime.open_order_form', status: 'completed', duration_ms: 700, counters: { frame_count: 1 } } ] }); state = advanced.state; assert.equal(advanced.snapshot.total_ms, 1_500); assert.deepEqual(advanced.snapshot.stages.map((stage) => stage.stage), ['open_order_form', 'wait_order_form']); assert.equal(advanced.snapshot.stages[0].duration_ms, 1_500); assert.deepEqual( advanced.snapshot.stages[0].details.map((detail) => detail.step), ['script_injection', 'action_runtime.open_order_form'] ); assert.equal(advanced.snapshot.stages[1].duration_ms, 0); assert.equal(state.active_stage, 'wait_order_form'); }); test('operation timing survives state loss without exposing absolute timestamps', () => { const state = timing.createState({ now_ms: 10_000, started_at_ms: 10_000, stage: 'preflight', status: 'running' }); const beforeSuspend = timing.snapshot(state, 12_000); const restored = timing.advance(null, { now_ms: 15_000, updated_at_ms: 12_000, existing_timing: beforeSuspend, stage: 'live_submit', status: 'running' }); assert.equal(restored.snapshot.total_ms, 5_000); assert.equal(restored.snapshot.stages[0].stage, 'preflight'); assert.equal(restored.snapshot.stages[0].duration_ms, 5_000); assert.equal(JSON.stringify(restored.snapshot).includes('started_at'), false); assert.equal(JSON.stringify(restored.snapshot).includes('updated_at'), false); }); test('operation timing terminal snapshot is bounded and strips arbitrary values', () => { let state = timing.createState({ now_ms: 1_000, stage: 'verification', status: 'running' }); const completed = timing.advance(state, { now_ms: 2_000, stage: 'verification', status: 'completed', details: [{ step: 'network_wait.verify_order_marker', status: 'completed', duration_ms: 450.4, counters: { request_count: 1, customer_name: '测试客户', negative: -1 }, url: 'https://example.invalid/customer', response: 'secret' }] }); state = completed.state; assert.equal(completed.terminal, true); assert.equal(completed.snapshot.status, 'completed'); assert.equal(completed.snapshot.total_ms, 1_000); assert.deepEqual(completed.snapshot.stages[0].details[0], { step: 'network_wait.verify_order_marker', status: 'completed', duration_ms: 450, counters: { request_count: 1 } }); assert.equal(Object.hasOwn(state, 'url'), false); }); test('a later reconciliation continuation does not count the human idle gap', () => { const terminal = { schema_version: timing.SCHEMA_VERSION, status: 'execution_uncertain', total_ms: 8_000, stage_count: 1, stages: [{ stage: 'lifecycle_reconciliation', status: 'execution_uncertain', duration_ms: 8_000 }] }; const resumed = timing.advance(null, { now_ms: 1_000_000, updated_at_ms: 1_000_000, existing_timing: terminal, stage: 'lifecycle_reconciliation', status: 'running' }); const reconciled = timing.advance(resumed.state, { now_ms: 1_001_200, stage: 'lifecycle_reconciliation', status: 'completed', details: [{ step: 'action_roundtrip.verify_lifecycle_operation', duration_ms: 900 }] }); assert.equal(reconciled.snapshot.total_ms, 9_200); assert.equal(reconciled.snapshot.stage_count, 2); assert.equal(reconciled.snapshot.stages[1].duration_ms, 1_200); assert.equal(reconciled.snapshot.stages[1].details[0].duration_ms, 900); }); test('background timing hook covers every executor through the shared result and page-action boundaries', async () => { const background = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); assert.match(background, /importScripts\([^\n]*'operation-timing\.js'/); assert.match(background, /async function setResult[\s\S]*operationTiming\.advance/); assert.match(background, /async function runPageAction[\s\S]*action_roundtrip\.\$\{actionCode\}/); assert.match(background, /network_wait\.\$\{actionCode\}/); assert.match(background, /businessTaskTimingStates/); assert.match(background, /async function runInlinePageAction/); assert.match(background, /async function canReusePageActionScripts/); assert.match(background, /assistant\.version === expectedVersion/); assert.match(background, /status: scriptsReused \? 'skipped' : 'completed'/); assert.match(background, /expectedFormProtectedFieldsSha256: preflight\.final_form\?\.post_intercept_protected_fields_sha256/); assert.match(background, /expectedRequestProtectedFieldsSha256: preflight\.final_form\?\.submit_protected_fields_sha256/); assert.match(background, /confirmedPrewriteNoErpWrite\(liveSubmit\)/); assert.match(background, /execution_phase: liveSubmit\.status === 'live_submit_completed'[\s\S]*'prewrite_blocked'/); assert.match(background, /if \(action === 'inspectOperationContext'\) return \['inpage\.js'\]/); assert.match(background, /if \(TEAM_SINGLE_PAGE_ACTIONS\.has\(action\)\) return \['source-region\.js', 'inpage\.js'\]/); assert.match(background, /storage_prune\.local_history/); assert.match( background, /async function reconcileLifecycleTask[\s\S]*resumeOperationTiming\(rootId, 'lifecycle_reconciliation'[\s\S]*finally \{\s*await clearOperationTimingContinuation\(rootId\);/ ); const actionAllowlist = background.match(/const TIMED_PAGE_ACTIONS = new Set\(\[([\s\S]*?)\]\);/)?.[1] || ''; for (const action of [ 'openOrderForm', 'pingOrderFrame', 'openTeamBatchForm', 'pingTeamBatchFrame', 'inspectOperationContext', 'preflightLifecycleOperation', 'resolveLifecycleOperation', 'prepareLifecycleOperation', 'liveSubmitLifecycleOperation', 'cleanupCompletedLifecycleDialog', 'verifyLifecycleOperation', 'createSplitParentLive', 'createSplitChildLive', 'createSplitChildBatchLive', 'exportConfirmationSources', 'preflightRawInstruction', 'liveSubmitApproved', 'verifyOrderMarker', 'returnToOrderList', 'preflightTeamBatchNative', 'liveSubmitTeamBatchApproved', 'verifyTeamBatchReceipts' ]) { assert.match(actionAllowlist, new RegExp(`['"]${action}['"]`), `${action} 缺少计时白名单`); } }); test('team-order preflight waits on ERP completion events without page-timer polling', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const preflight = inpage.slice( inpage.indexOf('async function preflightRawInstruction'), inpage.indexOf('async function liveSubmitApproved') ); const liveSubmit = inpage.slice( inpage.indexOf('async function liveSubmitApproved'), inpage.indexOf('async function verifyOrderMarker') ); assert.match(preflight, /Promise\.all\(endpointEntries\.map/); assert.match(preflight, /productEffectCompletion/); assert.match(preflight, /await productEffectCompletion/); assert.match(preflight, /preflight_lookup\.parallel_wall/); assert.match(preflight, /preflight_product_linkage/); assert.match(preflight, /await waitForJQueryAjaxIdle\(jq\)/); assert.match(preflight, /preflight_native_ajax_idle/); assert.match(preflight, /preflight_customer_restore/); assert.match(preflight, /dispatchNativeSideEffect: false/); assert.match(preflight, /customerRestoreReadiness/); assert.match(preflight, /customer_final_guard/); assert.match(preflight, /__ltjt_preflight_guard = true/); assert.match(preflight, /__ltjt_restore_timer/); assert.match(preflight, /intercept_guard_active/); assert.match(preflight, /postInterceptSerializedForm = serializeForm\(form\)/); assert.match(preflight, /post_intercept_protected_fields_sha256/); assert.ok( preflight.indexOf('submitIntercept = await interceptSubmit') < preflight.indexOf('postInterceptSerializedForm = serializeForm(form)'), 'post-intercept browser hash must be captured after SubmitInfoForm returns' ); assert.doesNotMatch(preflight, /waitForStableSignature/); assert.doesNotMatch(preflight, /quietWaitStartedAt|await sleep\(/); assert.match(liveSubmit, /submitCompletion/); assert.match(liveSubmit, /await submitCompletion/); assert.match(liveSubmit, /approvedFormProtectedFieldsSha256/); assert.match(liveSubmit, /approvedRequestProtectedFieldsSha256/); assert.match(liveSubmit, /network_request_attempted/); assert.match(liveSubmit, /liveSubmitPrewriteBlocked/); assert.match(liveSubmit, /Expected exactly one DoInfoJH ajax submit attempt/); assert.doesNotMatch(liveSubmit, /while \(Date\.now\(\) - started < 30000\)|await sleep\(250\)/); }); test('team-order frame discovery targets the exact ERP iframe instead of injecting into every frame', async () => { const [background, manifestText] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/manifest.json', import.meta.url), 'utf8') ]); const frameWait = background.slice( background.indexOf('async function waitForOrderFrame'), background.indexOf('async function openTeamBatchForm') ); assert.match(background, /chrome\.webNavigation\.getAllFrames/); assert.match(frameWait, /frameIds/); assert.doesNotMatch(frameWait, /allFrames: true/); assert.ok(JSON.parse(manifestText).permissions.includes('webNavigation')); }); test('team-order frame waits for native customer and staff lookup data before preflight', async () => { const background = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const start = background.indexOf('function lightweightOrderFrameProbe()'); const end = background.indexOf('function lightweightTeamBatchFrameProbe()', start); const probeSource = background.slice(start, end); const runProbe = (readyFields, activeAjaxCount = 0) => { const fields = Object.fromEntries(['zutuanshe', 'gendanren', 'xiaoshouren'].map((name) => [ name, { getAttribute: (attribute) => attribute === 'data' && readyFields.includes(name) ? 'native-options' : '' } ])); const form = { elements: { length: 822 }, querySelector: (selector) => fields[selector.slice(1)] || null }; const sandbox = { performance, document: { title: '计划下单/修改', querySelector: (selector) => selector === '#ListForm' ? form : null }, location: { href: 'https://lwlt.hisy.cc/System/Business/orders_add.asp' }, window: { frameElement: null, jQuery: { active: activeAjaxCount } }, URL }; vm.runInNewContext(`${probeSource};globalThis.result=lightweightOrderFrameProbe();`, sandbox); return JSON.parse(JSON.stringify(sandbox.result)); }; const loading = runProbe([]); assert.equal(loading.status, 'order_frame_loading'); assert.deepEqual(loading.missing, ['zutuanshe.data', 'gendanren.data', 'xiaoshouren.data']); const nativeAjaxPending = runProbe(['zutuanshe', 'gendanren', 'xiaoshouren'], 2); assert.equal(nativeAjaxPending.status, 'order_frame_loading'); assert.deepEqual(nativeAjaxPending.missing, ['jquery.ajax.pending:2']); assert.equal(runProbe(['zutuanshe', 'gendanren', 'xiaoshouren']).status, 'order_frame_ready'); }); test('team-order preflight waits for the native jQuery Ajax completion event', async () => { const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const inpage = source.replace( 'inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,', 'inspectWaitForJQueryAjaxIdle: waitForJQueryAjaxIdle,\n inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,' ); const handlers = new Map(); const eventTarget = { one(eventName, handler) { handlers.set(eventName, handler); return this; }, off(eventName) { handlers.delete(eventName); return this; } }; const jq = () => eventTarget; jq.active = 2; const pageWindow = { setTimeout, clearTimeout }; pageWindow.window = pageWindow; vm.runInNewContext(inpage, { window: pageWindow, document: {}, performance, setTimeout, clearTimeout }); const waiting = pageWindow.LTJTOrderAssistant.inspectWaitForJQueryAjaxIdle(jq, 1000); setTimeout(() => { jq.active = 0; const handler = [...handlers.entries()].find(([eventName]) => eventName.startsWith('ajaxStop'))?.[1]; handler?.(); }, 5); const result = await waiting; assert.deepEqual(JSON.parse(JSON.stringify(result)), { status: 'idle', initial_pending_ajax_count: 2, final_pending_ajax_count: 0, duration_ms: result.duration_ms }); assert.ok(result.duration_ms >= 0); }); test('extension result changes push immediately while polling remains as a fallback', async () => { const bridge = await readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8'); const app = await readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8'); assert.match(bridge, /changes\.businessTaskResults/); assert.match(bridge, /type: 'TASK_RESULT_CHANGED'/); assert.match(app, /message\.type === 'TASK_RESULT_CHANGED'/); assert.match(app, /pendingExtensionResults\.set/); assert.match(app, /while \(pendingExtensionResults\.has/); assert.match(app, /setInterval\([\s\S]*pollAllTaskResults/); }); test('platform exposes the sanitized timing breakdown without rendering raw network data', async () => { const app = await readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8'); const styles = await readFile(new URL('../LianSyn-platform/styles.css', import.meta.url), 'utf8'); assert.match(app, /function renderOperationTiming\(task\)/); assert.match(app, /task\?\.result\?\.operation_timing/); assert.match(app, /'storage_write\.result_state': '保存任务结果状态'/); assert.match(app, /'frame_discovery\.web_navigation': '定位目标 ERP Frame'/); assert.match(app, /'preflight_lookup\.parallel_wall': '四项资料并行查询'/); assert.match(app, /不记录 URL、表单值或响应正文/); assert.match(styles, /\.task-operation-timing-panel/); assert.doesNotMatch(app, /timing\.stages[\s\S]{0,200}(?:url|response_text|form_value)/); });