import test from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs'; const require = createRequire(import.meta.url); const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js'); const batch = require('../chrome-extension/ltjt-order-assistant/hotel-batch.js'); const guard = require('../chrome-extension/ltjt-order-assistant/execution-guard.js'); const timing = require('../chrome-extension/ltjt-order-assistant/operation-timing.js'); const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const slice = (a,b) => source.slice(source.indexOf(a),source.indexOf(b,source.indexOf(a))); const operation = { action:'arrangement_hotel', order_nature:'formal', submit_mode:'dry_run', data:{ existing_refs:{ identifier:'LW-SAMPLE' }, arrangements:[26,27,28].map(day => ({ mode:'create', resource:{ name:`酒店${day}TWN`, keyword:`酒店${day}TWN` }, start_date:`2026-09-${day}`, end_date:`2026-09-${day + 1}`, room_count:1 })) } }; function result(item, index, status = 'completed') { if (status === 'failed') return { status:'blocked', no_erp_write:true, write_attempted:false, message:'没有匹配候选' }; if (status === 'uncertain') return { status:'execution_uncertain', no_erp_write:false, write_attempted:true }; return { status:'completed', no_erp_write:false, write_attempted:true, report:{ status:'lifecycle_completed', resolved_refs:{ kind:'independent_order', identifier:'LW-SAMPLE' }, server_response:{ completed:true }, requery:{ matched:true, subpage:{ arrangement_target:{ row_id:String(index + 1), quantity_value:'1' }, checks:[ {field:'riqi0', actual:item.start_date, matched:true}, {field:'riqis0', actual:item.end_date, matched:true} ] } } } }; } function harness(failure = '', failureIndex = 1) { const saved = { businessTaskExecutions:{ TASK:{ state:'running', execution_id:'EXEC' } }, businessTaskResults:{}, businessTaskTimingStates:{} }; const calls = [], rootResults = []; const context = { console, performance, Date, Map, Set, Promise, hotelBatch:batch, executionGuard:guard, operationTiming:timing, operationPlans:plans, operationTimingDetailBuffers:new Map(), runningTasks:new Set(), isTaskCancelled:()=>false, throwIfTaskCancelled:()=>{}, nowIso:()=>new Date().toISOString(), withStorageMutation:fn=>fn(), queueTimedDetail:()=>{}, takeOperationTimingDetails:()=>[], chrome:{ storage:{ local:{ get:async()=>structuredClone(saved), set:async patch=>{ Object.assign(saved,structuredClone(patch)); const root=saved.businessTaskResults.TASK; if(root)rootResults.push(structuredClone(root)); } } } }, executeLifecycleOperation:async(id, op) => { calls.push(id); const index=calls.length-1; const status=index===failureIndex && failure ? failure : 'completed'; saved.businessTaskExecutions.TASK.state=status==='failed'?'submitted':'write_started'; if(failure==='throw' && index===failureIndex) throw new Error('lost connection'); await context.setResult(id,result(op.data.arrangement,index,status)); if(status==='completed')saved.businessTaskExecutions.TASK.state='submitted'; } }; vm.createContext(context); vm.runInContext(slice('async function setResult(', 'async function publish(')+'\n'+slice('async function publishHotelBatch(', 'async function runAutoTask(')+'\n'+slice('async function reconcileHotelBatch(', 'async function reconcileLifecycleTask('),context); return { context, saved, calls, rootResults }; } test('dispatch and schema accept only create batches with all ordinary per-row fields valid',()=>{ const ajv=new Ajv({strict:false});addFormats(ajv); const validate=ajv.compile(JSON.parse(readFileSync(new URL('../schemas/standard_system_operation.schema.json',import.meta.url)))); assert.deepEqual(validateProgramOperation(operation),[]); assert.equal(plans.validateDispatchOperation(operation).execution,'browser_hotel_batch'); assert.equal(validate(operation),true,JSON.stringify(validate.errors)); for(const mutate of [op=>{op.data.arrangements[2].room_count=0;},op=>{op.data.arrangements[1].mode='update';},op=>{op.data.arrangement=op.data.arrangements[0];},op=>{op.action='arrangement_vehicle';}]){ const bad=structuredClone(operation);mutate(bad);assert.equal(plans.validateDispatchOperation(bad).ok,false);assert.equal(validate(bad),false); } }); test('successful rows execute serially without prematurely finalizing the root task',async()=>{ const h=harness();await h.context.executeHotelBatch('TASK',operation); assert.deepEqual(h.calls,['TASK#hotel-1','TASK#hotel-2','TASK#hotel-3']); assert.equal(h.saved.businessTaskExecutions.TASK.state,'completed'); assert.equal(h.saved.businessTaskResults.TASK.status,'completed'); assert.ok(h.rootResults.filter(row=>row.hotel_batch?.items.some(item=>item.status==='running')).every(row=>row.write_attempted===true && row.no_erp_write===false), 'a parent lease expiry must never infer no write from a still-running child'); assert.ok(h.rootResults.filter(row=>row.status==='completed').every(row=>row.hotel_batch.items.every(item=>item.status==='completed'))); }); test('prewrite failure stops subsequent rows, retains saved prefix and stays known partial failure',async()=>{ const h=harness('failed');await h.context.executeHotelBatch('TASK',operation); assert.equal(h.calls.length,2);const root=h.saved.businessTaskResults.TASK; assert.equal(root.status,'blocked');assert.equal(root.no_erp_write,false);assert.equal(root.write_attempted,true); assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['completed','failed','not_started']); assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked');assert.match(root.message,/未执行 1 条/); }); test('uncertain or missing response stops without retry and preserves both prefix and pending tail',async()=>{ for(const failure of ['uncertain','throw']){ const h=harness(failure);await h.context.executeHotelBatch('TASK',operation); assert.equal(h.calls.length,2);assert.equal(h.saved.businessTaskResults.TASK.status,'execution_uncertain'); assert.equal(h.saved.businessTaskResults.TASK.hotel_batch.items[2].status,'not_started'); } }); test('read-only reconciliation resolves only the uncertain row and never starts the pending tail',async()=>{ const h=harness('uncertain');await h.context.executeHotelBatch('TASK',operation); const root=h.saved.businessTaskResults.TASK; const item=root.hotel_batch.items[1]; const proof=result(item.arrangement,1); item.result.report={...proof.report,status:'saved_unverified',requery:{matched:false}}; h.saved.businessTasks={'TASK#hotel-2':{operation:{...operation,data:{existing_refs:{identifier:'LW-SAMPLE'},arrangement:item.arrangement}}}}; h.context.operationPlans={...plans,validateOperation:()=>({ok:true,execution:'browser_lifecycle'})}; h.context.findOrOpenErpTab=async()=>({id:1}); let reads=0; h.context.runLifecycleReadOnlyVerification=async()=>{reads++;return {resolved:true,verification:{requery:proof.report.requery}};}; const reply=await h.context.reconcileHotelBatch('TASK',operation,root,h.saved); assert.equal(reply.status,'blocked');assert.equal(reply.reconciliation_resolved,true); assert.equal(reads,1);assert.equal(h.calls.length,2); assert.deepEqual(h.saved.businessTaskResults.TASK.hotel_batch.items.map(row=>row.status),['completed','completed','not_started']); assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked'); }); test('timeout preserves an already-saved child and classifies a missing child as uncertain without replay',async()=>{ for(const savedChild of [true,false]){ const h=harness(); const pending={version:1,group_number:'LW-SAMPLE',items:operation.data.arrangements.map((arrangement,index)=>({index,arrangement,status:index===0?'completed':index===1?'running':'not_started',...(index===0?{result:result(arrangement,0)}:{})}))}; await h.context.publishHotelBatch('TASK',operation,pending); if(savedChild)h.saved.businessTaskResults['TASK#hotel-2']=result(operation.data.arrangements[1],1); assert.equal(await h.context.stopHotelBatch('TASK',operation,'timeout'),true); const root=h.saved.businessTaskResults.TASK; assert.equal(root.status,savedChild?'blocked':'execution_uncertain'); assert.equal(root.hotel_batch.items[0].status,'completed'); assert.equal(root.hotel_batch.items[1].status,savedChild?'completed':'uncertain'); assert.equal(root.hotel_batch.items[2].status,'not_started');assert.equal(h.calls.length,0); } }); test('production Docker runtime includes the shared batch evidence validator',()=>{ const dockerfile=readFileSync(new URL('../Dockerfile',import.meta.url),'utf8'); const runtime=dockerfile.split('AS runtime')[1]; assert.match(runtime,/COPY[^\n]*chrome-extension\/ltjt-order-assistant\/hotel-batch\.js[^\n]*\.\/chrome-extension\/ltjt-order-assistant\//); }); test('a proven prewrite failure on the first row reports zero writes and leaves the rest untouched',async()=>{ const h=harness('failed',0);await h.context.executeHotelBatch('TASK',operation); const root=h.saved.businessTaskResults.TASK; assert.equal(h.calls.length,1);assert.equal(root.status,'blocked'); assert.equal(root.no_erp_write,true);assert.equal(root.write_attempted,false); assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['failed','not_started','not_started']); assert.match(root.message,/未完成/); });