339 lines
18 KiB
JavaScript
339 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
const DEFAULT_CDP = '/Users/inmanx/.agents/skills/chrome-cdp/scripts/cdp.mjs';
|
|
const DEFAULT_PLATFORM_TARGET = 'D484CA70';
|
|
const DEFAULT_ERP_TARGET = 'DA600CC0';
|
|
const CREATE_ACTIONS = new Set([
|
|
'team_order_create',
|
|
'team_order_batch_create',
|
|
'shared_plan_create',
|
|
'shared_child_order_create'
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
fixture: '',
|
|
taskId: '',
|
|
confirm: false,
|
|
allowLiveWrite: false,
|
|
platformTarget: DEFAULT_PLATFORM_TARGET,
|
|
erpTarget: DEFAULT_ERP_TARGET,
|
|
cdp: process.env.CDP_CLI || DEFAULT_CDP,
|
|
expectedRunId: '',
|
|
parseTimeoutMs: 30_000,
|
|
resultTimeoutMs: 120_000
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const value = argv[index];
|
|
if (value === '--fixture') args.fixture = argv[++index] || '';
|
|
else if (value === '--task-id') args.taskId = argv[++index] || '';
|
|
else if (value === '--confirm') args.confirm = true;
|
|
else if (value === '--allow-live-write') args.allowLiveWrite = true;
|
|
else if (value === '--platform-target') args.platformTarget = argv[++index] || '';
|
|
else if (value === '--erp-target') args.erpTarget = argv[++index] || '';
|
|
else if (value === '--cdp') args.cdp = argv[++index] || '';
|
|
else if (value === '--expected-run-id') args.expectedRunId = argv[++index] || '';
|
|
else if (value === '--parse-timeout-ms') args.parseTimeoutMs = Number(argv[++index]);
|
|
else if (value === '--result-timeout-ms') args.resultTimeoutMs = Number(argv[++index]);
|
|
else throw new Error(`unknown argument: ${value}`);
|
|
}
|
|
if (!args.fixture) throw new Error('--fixture is required');
|
|
if (!args.expectedRunId) throw new Error('--expected-run-id is required');
|
|
if (!Number.isFinite(args.parseTimeoutMs) || args.parseTimeoutMs < 1_000) throw new Error('--parse-timeout-ms must be at least 1000');
|
|
if (!Number.isFinite(args.resultTimeoutMs) || args.resultTimeoutMs < 5_000) throw new Error('--result-timeout-ms must be at least 5000');
|
|
return args;
|
|
}
|
|
|
|
function wait(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function cdp(args, command, target, ...rest) {
|
|
return execFileSync(process.execPath, [args.cdp, command, target, ...rest], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 32 * 1024 * 1024
|
|
}).trim();
|
|
}
|
|
|
|
function evaluate(args, target, expression) {
|
|
return cdp(args, 'eval', target, expression);
|
|
}
|
|
|
|
function parseJsonOutput(value, label) {
|
|
try {
|
|
return JSON.parse(String(value || '').trim());
|
|
} catch (error) {
|
|
throw new Error(`${label} returned non-JSON output: ${String(value || '').slice(0, 500)}`);
|
|
}
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function coreOperation(operation, expected) {
|
|
const copy = structuredClone(operation);
|
|
if (copy.data && expected?.data) {
|
|
if (!Object.hasOwn(expected.data, 'attachments')) delete copy.data.attachments;
|
|
if (!Object.hasOwn(expected.data, 'system_defaults')) delete copy.data.system_defaults;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function expectedPreConfirmationOperation(operation) {
|
|
const copy = structuredClone(operation);
|
|
if (!CREATE_ACTIONS.has(copy?.action) && copy?.source?.test_context) {
|
|
copy.source.test_context.allow_live_write = false;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function validateFixtureSafety(operation, expectedRunId, allowLiveWrite = false) {
|
|
const context = operation?.source?.test_context || {};
|
|
const refs = operation?.data?.existing_refs || {};
|
|
const data = operation?.data || {};
|
|
const errors = [];
|
|
if (CREATE_ACTIONS.has(operation?.action)) {
|
|
if (allowLiveWrite) errors.push('creation_must_not_use_allow_live_write_flag');
|
|
const dates = Array.isArray(data.departure_dates) ? data.departure_dates : [];
|
|
if (!dates.length || dates.some((date) => !/^2026-09-\d{2}$/.test(String(date || '')))) errors.push('creation_dates_outside_2026_09');
|
|
const contextDates = Array.isArray(context.target_dates) ? context.target_dates.map(String) : [];
|
|
if (context.run_id !== expectedRunId) errors.push('creation_run_id_mismatch');
|
|
if (context.marker !== 'TEST-202609') errors.push('creation_marker_mismatch');
|
|
if (context.account !== '测试ai员工账号') errors.push('creation_account_mismatch');
|
|
if (!String(context.native_baseline_id || '').trim()) errors.push('creation_native_baseline_missing');
|
|
if (context.live_window !== '2026-09') errors.push('creation_live_window_mismatch');
|
|
if (dates.some((date) => !contextDates.includes(String(date)))) errors.push('creation_target_dates_mismatch');
|
|
if (context.allow_live_write === true) errors.push('creation_must_not_self_authorize_live_write');
|
|
const suffix = String(data.order_number?.suffix || '');
|
|
const parent = String(data.existing_refs?.parent_group_no || data.parent_group_no || '');
|
|
if (operation.action === 'shared_child_order_create') {
|
|
if (!parent.includes('TEST-202609') || !String(data.special_requests || '').includes('TEST-202609')) errors.push('shared_child_creation_marker_mismatch');
|
|
} else if (!suffix.includes('TEST-202609')) errors.push('creation_suffix_marker_mismatch');
|
|
const splitOrder = data.split_order && typeof data.split_order === 'object' && !Array.isArray(data.split_order)
|
|
? data.split_order : {};
|
|
const sharedBatchSplitProbe = operation.action === 'shared_plan_create'
|
|
&& dates.length >= 2
|
|
&& (splitOrder.customer !== undefined || splitOrder.passenger_counts !== undefined);
|
|
if (sharedBatchSplitProbe) {
|
|
if (context.phase !== 'shared_batch_split_order_probe') errors.push('shared_batch_probe_phase_mismatch');
|
|
}
|
|
return errors;
|
|
}
|
|
if (context.run_id !== expectedRunId) errors.push('run_id_mismatch');
|
|
if (context.marker !== 'TEST-202609' || refs.marker !== 'TEST-202609') errors.push('marker_mismatch');
|
|
if (context.account !== '测试ai员工账号' || refs.owner_account !== '测试ai员工账号') errors.push('owner_mismatch');
|
|
const deleteLiveWrite = operation?.action === 'order_delete';
|
|
if (deleteLiveWrite && context.allow_live_write !== true) {
|
|
errors.push('delete_fixture_allow_live_write_must_be_true');
|
|
}
|
|
if (!deleteLiveWrite && context.allow_live_write === true && !allowLiveWrite) {
|
|
errors.push('non_delete_live_write_requires_allow_live_write_flag');
|
|
}
|
|
if (!deleteLiveWrite && context.allow_live_write !== true && allowLiveWrite) {
|
|
errors.push('allow_live_write_flag_requires_fixture_authorization');
|
|
}
|
|
if (context.live_window !== '2026-09') errors.push('live_window_mismatch');
|
|
const targetDate = String(refs.departure_date || '');
|
|
if (targetDate && !/^2026-09-\d{2}$/.test(targetDate)) errors.push('target_date_outside_2026_09');
|
|
if (!Array.isArray(context.created_refs) || !context.created_refs.some((item) => (
|
|
item?.identifier === refs.identifier
|
|
&& String(item?.tid || '') === String(refs.tid || '')
|
|
&& (!refs.ddid || String(item?.ddid || '') === String(refs.ddid))
|
|
))) errors.push('target_not_in_created_refs');
|
|
return errors;
|
|
}
|
|
|
|
function localTaskExpression(taskId) {
|
|
return `(()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});return JSON.stringify(t&&{task_id:t.task_id,status:t.status,stage:t.stage,message:t.message,parser:t.parse_response?.parser_prompt_version,action:t.operation?.action,data_keys:Object.keys(t.operation?.data||{}),failure:t.failure,success_receipt:t.success_receipt})})()`;
|
|
}
|
|
|
|
function exactRemoteTaskExpression(taskId) {
|
|
return `(async()=>{const response=await fetch('/api/tasks/'+encodeURIComponent(${JSON.stringify(taskId)}),{credentials:'same-origin'});if(!response.ok)throw new Error('exact_task_http_'+response.status);const payload=await response.json();const t=payload?.task;if(!t)throw new Error('exact_task_missing');const index=taskStore.findIndex(x=>x.task_id===t.task_id);if(index>=0)taskStore[index]=t;else taskStore.unshift(t);return JSON.stringify({task_id:t.task_id,status:t.status,stage:t.stage,message:t.message,parser:t.parse_response?.parser_prompt_version,action:t.operation?.action,data_keys:Object.keys(t.operation?.data||{}),failure:t.failure,success_receipt:t.success_receipt})})()`;
|
|
}
|
|
|
|
async function waitForParsedTask(args, taskId) {
|
|
const startedAt = Date.now();
|
|
let last = null;
|
|
while (Date.now() - startedAt < args.parseTimeoutMs) {
|
|
try {
|
|
last = parseJsonOutput(evaluate(args, args.platformTarget, localTaskExpression(taskId)), 'task state');
|
|
} catch {
|
|
last = null;
|
|
}
|
|
if (last && !['parse_queued', 'parse_running'].includes(last.status)) return last;
|
|
try {
|
|
last = parseJsonOutput(evaluate(args, args.platformTarget, exactRemoteTaskExpression(taskId)), 'exact remote task state');
|
|
if (last && !['parse_queued', 'parse_running'].includes(last.status)) return last;
|
|
} catch {
|
|
// The next exact read retries the same durable task; it never creates a duplicate.
|
|
}
|
|
await wait(500);
|
|
}
|
|
throw new Error(`task ${taskId} did not leave parse queue within ${args.parseTimeoutMs}ms; do not create a duplicate task`);
|
|
}
|
|
|
|
async function waitForCreatedTaskId(args, previousTaskId) {
|
|
const startedAt = Date.now();
|
|
let latest = '';
|
|
while (Date.now() - startedAt < args.parseTimeoutMs) {
|
|
try {
|
|
const state = parseJsonOutput(evaluate(
|
|
args,
|
|
args.platformTarget,
|
|
`JSON.stringify({task_id:String(currentTaskId||'')})`
|
|
), 'created task state');
|
|
latest = String(state?.task_id || '');
|
|
if (latest && latest !== previousTaskId) return latest;
|
|
} catch {
|
|
// The scheduled platform POST is still pending; do not schedule another.
|
|
}
|
|
await wait(500);
|
|
}
|
|
throw new Error(`scheduled task creation did not publish a new task id within ${args.parseTimeoutMs}ms; do not create a duplicate, query the expected run id`);
|
|
}
|
|
|
|
function terminalExtensionResult(result) {
|
|
const status = String(result?.status || '');
|
|
const stage = String(result?.stage || '');
|
|
return /(?:completed|blocked|uncertain|pending)$/.test(status)
|
|
|| /(?:completed|blocked|uncertain|reconciliation)$/.test(stage);
|
|
}
|
|
|
|
async function waitForExtensionResult(args, taskId) {
|
|
const startedAt = Date.now();
|
|
let latest = null;
|
|
while (Date.now() - startedAt < args.resultTimeoutMs) {
|
|
const expression = `(async()=>JSON.stringify(await sendToExtension('GET_TASK_RESULT',{task_id:${JSON.stringify(taskId)}},15000)))()`;
|
|
try {
|
|
const response = parseJsonOutput(evaluate(args, args.platformTarget, expression), 'extension result');
|
|
latest = response?.result || null;
|
|
if (latest && terminalExtensionResult(latest)) return latest;
|
|
} catch {
|
|
// A transient bridge/page timeout does not authorize a resubmission.
|
|
}
|
|
await wait(2_000);
|
|
}
|
|
throw new Error(`task ${taskId} has no terminal extension result after ${args.resultTimeoutMs}ms; do not resubmit, inspect the same task`);
|
|
}
|
|
|
|
async function publishExistingResult(args) {
|
|
try {
|
|
evaluate(args, args.platformTarget, '(()=>{void (async()=>{await pollAllTaskResults();await syncRemoteTasks()})().catch(()=>{});return "scheduled"})()');
|
|
} catch {
|
|
// The extension result remains durable even if this UI synchronization times out.
|
|
}
|
|
}
|
|
|
|
function resultSummary(taskId, parsed, expectedHash, actualHash, result, platformTask) {
|
|
const report = result?.report || result || {};
|
|
return {
|
|
task_id: taskId,
|
|
action: parsed.action,
|
|
canonical_core_sha256: actualHash,
|
|
expected_core_sha256: expectedHash,
|
|
canonical_core_exact: expectedHash === actualHash,
|
|
platform: platformTask,
|
|
extension: {
|
|
execution_id: result?.execution_id || report.execution_id || '',
|
|
status: result?.status || '',
|
|
stage: result?.stage || '',
|
|
message: result?.message || '',
|
|
write_attempted: result?.write_attempted === true,
|
|
no_erp_write: result?.no_erp_write === true,
|
|
blockers: result?.blockers || report.blockers || [],
|
|
resolved_refs: result?.resolved_refs || report.resolved_refs || null,
|
|
before_snapshot: result?.before_snapshot || report.before_snapshot || null,
|
|
changes: report.changes || [],
|
|
preflight: result?.preflight || report.preflight || null,
|
|
server_response: result?.server_response || report.server_response || null,
|
|
requery: result?.requery || report.requery || null,
|
|
erp_receipt: result?.erp_receipt || report.erp_receipt || null,
|
|
verification: result?.verification || report.verification || null,
|
|
reconciliation: result?.reconciliation || report.reconciliation || null,
|
|
submit: result?.submit || report.submit || null,
|
|
side_effects: result?.side_effects || report.side_effects || [],
|
|
warnings: result?.warnings || report.warnings || [],
|
|
manual_review_required: result?.manual_review_required === true || report.manual_review_required === true
|
|
}
|
|
};
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const rawFixture = await readFile(args.fixture, 'utf8');
|
|
const fixture = JSON.parse(rawFixture);
|
|
const fixtureSafetyErrors = validateFixtureSafety(fixture, args.expectedRunId, args.allowLiveWrite);
|
|
if (fixtureSafetyErrors.length) throw new Error(`fixture safety check failed: ${fixtureSafetyErrors.join(',')}`);
|
|
|
|
let taskId = String(args.taskId || '');
|
|
if (!taskId) {
|
|
const encoded = Buffer.from(rawFixture).toString('base64');
|
|
const createExpression = `(()=>{const input=document.querySelector('#rawInstruction');if(!input)throw new Error('rawInstruction_missing');const previousTaskId=String(currentTaskId||'');input.value=new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(encoded)}),c=>c.charCodeAt(0)));input.dispatchEvent(new Event('input',{bubbles:true}));void createTask().catch(()=>{});return JSON.stringify({scheduled:true,previous_task_id:previousTaskId})})()`;
|
|
const scheduled = parseJsonOutput(evaluate(args, args.platformTarget, createExpression), 'task creation scheduling');
|
|
if (scheduled.scheduled !== true) throw new Error('task creation was not scheduled');
|
|
taskId = await waitForCreatedTaskId(args, String(scheduled.previous_task_id || ''));
|
|
}
|
|
|
|
const parsedState = await waitForParsedTask(args, taskId);
|
|
if (parsedState.status !== 'awaiting_confirmation') {
|
|
console.log(JSON.stringify({ task_id: taskId, status: parsedState.status, parsed_state: parsedState }, null, 2));
|
|
process.exitCode = 2;
|
|
} else {
|
|
const expectedPreConfirmation = expectedPreConfirmationOperation(fixture);
|
|
const expectedCore = coreOperation(expectedPreConfirmation, fixture);
|
|
const expectedHash = sha256(JSON.stringify(expectedCore));
|
|
const expectedHasAttachments = Object.hasOwn(fixture.data || {}, 'attachments');
|
|
const expectedHasSystemDefaults = Object.hasOwn(fixture.data || {}, 'system_defaults');
|
|
const hashExpression = `(async()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});if(!t)throw new Error('task_not_found');const a=structuredClone(t.operation);${expectedHasAttachments ? '' : 'delete a.data.attachments;'}${expectedHasSystemDefaults ? '' : 'delete a.data.system_defaults;'}const d=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(a)));return [...new Uint8Array(d)].map(b=>b.toString(16).padStart(2,'0')).join('')})()`;
|
|
const actualHash = evaluate(args, args.platformTarget, hashExpression);
|
|
if (actualHash !== expectedHash) throw new Error(`canonical core mismatch for ${taskId}: expected ${expectedHash}, actual ${actualHash}; task remains unconfirmed`);
|
|
|
|
const guardExpression = `(async()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});return JSON.stringify({task_id:t?.task_id,status:t?.status,action:t?.operation?.action,parser:t?.parse_response?.parser_prompt_version,org_automation:organizationAutomationEnabled,required_version:REQUIRED_EXTENSION_VERSION,extension:await sendToExtension('PING')})})()`;
|
|
const guard = parseJsonOutput(evaluate(args, args.platformTarget, guardExpression), 'platform guard');
|
|
const erpGuard = guard.extension?.erp_session || null;
|
|
const guardErrors = [];
|
|
if (guard.task_id !== taskId || guard.status !== 'awaiting_confirmation') guardErrors.push('task_not_awaiting_confirmation');
|
|
if (guard.action !== fixture.action || guard.parser !== 'canonical-json-v1') guardErrors.push('canonical_task_identity_mismatch');
|
|
if (guard.org_automation !== false) guardErrors.push('organization_automation_must_be_false');
|
|
if (!guard.extension?.ok || guard.extension.version !== guard.required_version || guard.extension.erp_automation_enabled !== true) guardErrors.push('extension_runtime_guard_failed');
|
|
if (!erpGuard?.ok || !erpGuard.tab_present || !erpGuard.account_matched || erpGuard.login_or_permission_error) {
|
|
guardErrors.push('erp_test_account_not_present');
|
|
}
|
|
if (guardErrors.length) throw new Error(`runtime safety check failed: ${guardErrors.join(',')}; task remains unconfirmed`);
|
|
|
|
if (!args.confirm) {
|
|
console.log(JSON.stringify({
|
|
task_id: taskId,
|
|
status: parsedState.status,
|
|
action: fixture.action,
|
|
canonical_core_sha256: actualHash,
|
|
canonical_core_exact: true,
|
|
guard,
|
|
erp_guard: erpGuard,
|
|
confirmed: false
|
|
}, null, 2));
|
|
} else {
|
|
evaluate(args, args.platformTarget, `(()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});if(!t)throw new Error('task_not_found');if(t.status!=='awaiting_confirmation')throw new Error('task_not_awaiting_confirmation');void confirmAndSubmitToErpPlugin(t).catch(()=>{});return JSON.stringify({submitted:true,task_id:t.task_id})})()`);
|
|
try {
|
|
cdp(args, 'evalraw', args.erpTarget, 'Page.bringToFront', '{}');
|
|
} catch {
|
|
// Foregrounding only prevents background timer throttling; it is not a write prerequisite.
|
|
}
|
|
const result = await waitForExtensionResult(args, taskId);
|
|
await publishExistingResult(args);
|
|
let platformTask = null;
|
|
try {
|
|
platformTask = parseJsonOutput(evaluate(args, args.platformTarget, localTaskExpression(taskId)), 'final task state');
|
|
} catch {
|
|
platformTask = { task_id: taskId, status: 'ui_sync_unavailable' };
|
|
}
|
|
const summary = resultSummary(taskId, fixture, expectedHash, actualHash, result, platformTask);
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
if (!/(?:completed)$/.test(String(result.status || ''))) process.exitCode = 2;
|
|
}
|
|
}
|