246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
|
|
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',
|
|
'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, /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('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)/);
|
|
});
|