Files
LWLT-AIBOT/tools/operation-timing.test.mjs
2026-09-09 17:24:16 +08:00

333 lines
15 KiB
JavaScript

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)/);
});