* feat(history): add bounded chronological read projections (#209) * fix(history): validate internal continuation positions (#209) * feat(history): expose fenced history windows and run details (#209) * fix(history): release retired projection connections (#209) * feat(history): resolve reported outputs through current authorization (#209) * fix(history): reject invalid inspection targets (#209) * feat(history): add scoped history state and current-file adapter (#209) * fix(history): preserve slideshow and clear stale reentry actions (#209) * feat(history): render recorded executions and frozen run details (#209) * fix(history): recover receipts and refresh capture evidence (#209) * test(history): verify chronological history across restore and host modes (#209) * fix(history): enforce chronological marker visual parity (#209) * fix(history): resolve final chronological review findings Address #209 recovery, bounded visit restoration, exact receipt availability, and portable regression coverage. * test(history): repair Windows fixtures and review feedback Address #209 and PR #227 native path, cleanup, media and review-comment expectations. Document Windows portability preflight without changing production behavior. * ci: raise the viewerjs job budget for the larger browser suite The cross-browser workflows suite grew from 396 to 496 tests on this branch, which pushed the whole viewerjs job to about 25.5 minutes against the 25 minute cap set in #181. Two exact-head runs on this branch timed out without any test failing: run 39173019283 was killed on test 474 of 496, and run 39175125474 printed "496 passed (12.8m)" and was then cancelled during Playwright shutdown. Raise only the job budget. Browser projects, workers and per-test timeouts are unchanged. Splitting the browsers into parallel jobs is tracked separately.
279 lines
38 KiB
JavaScript
279 lines
38 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {createRequire} from 'node:module';
|
|
const {createApiClient} = createRequire(import.meta.url)('../../web/js/api-client.js');
|
|
const {extractGenerationFacts} = createRequire(import.meta.url)('../../web/js/comfy-metadata.js');
|
|
import {createInspectionController} from '../../web/js/workspace/inspection-state.mjs';
|
|
import {reportedLocationAdapter} from '../../web/js/workspace/history-media.mjs';
|
|
const m = await import('../../web/js/workspace/history-state.mjs').catch(e => {if(e.code !== 'ERR_MODULE_NOT_FOUND') throw e; return {};});
|
|
const epoch='11111111-1111-1111-1111-111111111111', session='22222222-2222-2222-2222-222222222222';
|
|
const id=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`;
|
|
const digest=n=>String(n).repeat(64), fields=['raw','effective','prepend','append'];
|
|
const proof=(provenance='recorded')=>({provenance,source:provenance==='recorded'?'runtime_return':'unknown',reason:provenance==='recorded'?null:'missing'});
|
|
function context(anchor='1',branch='a') {return {anchor_id:anchor,context_id:digest(branch),positive_raw_text_id:digest('e'),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d'),positive:{raw:'positive\n猫',effective:null,prepend:'',append:''},negative:{raw:'',effective:'negative',prepend:'',append:''},evidence:{format_version:1,role_provenance:'inferred',positive:Object.fromEntries(fields.map(f=>[f,proof(f==='effective'?'unknown':'recorded')])),negative:Object.fromEntries(fields.map(f=>[f,proof()]))},positive_prompt_id:7,negative_prompt_id:8,pair_id:9,reference_epoch:epoch,navigation:{positive_available:true,negative_available:true,pair_available:true}};}
|
|
const capture={state:'complete',reason:'captured',format_version:1};
|
|
const output={state:'complete',reason:'reported',format_version:1,source:'host_final_history',retained_occurrence_count:4,list_status:'complete'};
|
|
function run(n,branch='a') {return {run_id:id(n),session_id:session,storage_epoch:epoch,job_id:id(99),record_version:1,queue_item_id:String(n),observed_dequeue_at:String(9007199254740990n+BigInt(n)),observed_callback_at:null,executor_started_at:null,terminal_event_at:null,outcome:'observed',reason:null,diagnostics_omitted:false,diagnostics_available:false,context_capture:capture,output_summary:output,matching_anchors:[{anchor_id:'1',context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d')}]};}
|
|
function windowPage(items=[run(1),run(2),run(3,'b')],more=false) {const summaries=new Map();for(const r of items) {const a=r.matching_anchors[0];if(!summaries.has(a.context_id)) summaries.set(a.context_id,{context_id:a.context_id,positive_snapshot_id:a.positive_snapshot_id,negative_snapshot_id:a.negative_snapshot_id,negative_raw:{state:'explicit_empty',preview:'',truncated:false,evidence:[]}});summaries.get(a.context_id).negative_raw.evidence.push({run_id:r.run_id,anchor_id:'1',...proof()});}return {success:true,history_version:1,history_epoch:epoch,group:{positive_id:7,raw_text_id:digest('e'),relation:'exact_frozen_raw_text',scope_status:'matched'},recorded_run_count:3,resume_cursor:items.length?'resume-token':null,items,context_summaries:[...summaries.values()],has_more:more,next_cursor:more?'window-token':null};}
|
|
const detail=(n=1)=>({success:true,history_version:1,history_epoch:epoch,run:run(n),contexts:[context(),context('2','b')],context_capture:capture,output_summary:output});
|
|
function receipt(node='1',ordinal=0) {return {occurrence:{run_id:id(1),output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'image.png',path_style:'posix',root_key:digest('a')},file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};}
|
|
const outputs=(items=[receipt('1',0),receipt('1',1)],more=true)=>({success:true,history_version:1,history_epoch:epoch,capture:{state:'complete',reason:'reported',format_version:1,source:'host_final_history'},capture_fingerprint:digest('f'),retained_occurrence_count:4,list_status:'complete',items,has_more:more,next_cursor:more?'outputs-token':null});
|
|
function setup(restore) {const requests=[],changes=[];assert.equal(typeof m.createHistoryController,'function','history controller is exported');const c=m.createHistoryController({positiveId:7,restore,onChange:s=>changes.push(s),api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({url,signal,resolve,reject}))}});return {c,requests,changes};}
|
|
async function loaded(restore) {const s=setup(restore),p=s.c.load();s.requests.at(-1).resolve(windowPage());await p;return s;}
|
|
async function selected() {const s=await loaded(),p=s.c.selectRun(id(1));s.requests.at(-1).resolve(detail());await p;return s;}
|
|
|
|
test('chronology keeps A A B as three runs and two branches with exact decimal ordering',async()=>{const {c,requests}=await loaded();assert.deepEqual(c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);assert.equal(c.state.window.context_summaries.length,2);assert.equal(c.state.window.items[2].observed_dequeue_at,'9007199254740993');assert.equal(requests[0].url,'/prompt_manager/library/7/history?limit=40');assert.equal(c.state.window.recorded_run_count,3);c.dispose();});
|
|
|
|
test('earlier windows prepend full identities once and refresh replaces bounded count',async()=>{const s=setup();let p=s.c.load();s.requests[0].resolve(windowPage([run(2),run(3,'b')],true));await p;p=s.c.loadEarlier();assert.match(s.requests[1].url,/cursor=window-token/);s.requests[1].resolve(windowPage([run(1),run(2)],false));await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);p=s.c.refresh();s.requests[2].resolve({...windowPage([run(3)]),recorded_run_count:1});await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(3)]);assert.equal(s.c.state.window.recorded_run_count,1);s.c.dispose();});
|
|
|
|
test('malformed windows never enable actions',async()=>{for(const mutate of [p=>p.items[0].queue_item_id='01',p=>p.items[0].observed_dequeue_at='9223372036854775808',p=>p.items[0].run_id='bad',p=>p.history_epoch='bad',p=>p.group.positive_id=8,p=>p.context_summaries[0].negative_raw.preview='x'.repeat(101),p=>p.has_more=true,p=>p.next_cursor='',p=>p.items[0].matching_anchors[0].context_id='x',p=>p.items[0].observed_callback_at=1,p=>p.recorded_run_count=-1]) {const {c,requests}=setup();const p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
|
|
|
test('frozen roles retain explicit empty and unknown values with their own evidence',()=>{assert.equal(typeof m.frozenPromptRoles,'function');const ctx=context(),raw=m.frozenPromptRoles(ctx,'raw'),effective=m.frozenPromptRoles(ctx,'effective');assert.equal(raw.negative.status,'resolved');assert.equal(raw.negative.text,'');assert.deepEqual(raw.negative.evidence,proof());assert.equal(effective.positive.status,'unresolved');assert.equal(effective.positive.text,null);assert.deepEqual(effective.positive.evidence,proof('unknown'));assert.equal(effective.negative.text,'negative');assert.equal(raw.positive.text,'positive\n猫');assert.throws(()=>m.frozenPromptRoles(ctx,'wrong'),TypeError);});
|
|
|
|
test('detail validates exact roles evidence IDs and epoch before selection',async()=>{for(const mutate of [p=>p.history_epoch=id(4),p=>p.run.run_id=id(2),p=>p.contexts[0].negative_snapshot_id=null,p=>p.contexts[0].positive.raw=42,p=>p.contexts[0].evidence.positive.raw.source='unknown',p=>p.contexts[0].navigation.pair_available=1,p=>p.contexts[0].positive_prompt_id=0,p=>p.contexts.push(p.contexts[0])]) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error || c.state.refreshRequired);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
|
|
|
test('dependent selections abort late run output success and errors including disposal',async()=>{const {c,requests,changes}=await loaded();const p=c.selectRun(id(1)),q=c.selectRun(id(2));assert.equal(requests[1].signal.aborted,true);requests[2].resolve(detail(2));await q;requests[1].reject({status:409,code:'history-changed'});await p;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.refreshRequired,null);const r=c.selectRun(id(1));requests[3].resolve(detail());await r;for(const change of [()=>c.selectContext('2'),()=>c.setMode('effective')]) {const a=c.loadOutputs();const request=requests.at(-1);change();assert.equal(request.signal.aborted,true);request.resolve(outputs());await a;assert.equal(c.state.outputs.items.length,0);}const a=c.loadOutputs(),request=requests.at(-1);c.dispose();const count=changes.length;request.reject({status:409,code:'history-changed'});await a;assert.equal(changes.length,count);});
|
|
|
|
test('output pagination preserves binary order and exact repeated receipt selection on retry',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:1});assert.equal(c.state.selection.receipt.image_ordinal,1);p=c.loadOutputs();const url=requests.at(-1).url;requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');p=c.retryOutputs();assert.equal(requests.at(-1).url,url);requests.at(-1).resolve(outputs([receipt('\ue000'),receipt('𐀀')],false));await p;assert.deepEqual(c.state.outputs.items.map(r=>r.occurrence.output_node_id),['1','1','\ue000','𐀀']);assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();});
|
|
|
|
test('receipt selection invalidates pending responses without losing loaded coordinates',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();const pending=requests.at(-1);c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(pending.signal.aborted,true);pending.reject({status:409,code:'capture-changed'});await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.selection.receipt.image_ordinal,0);assert.equal(c.state.refreshRequired,null);c.dispose();});
|
|
|
|
test('response-limit retries only the failed cursor at the permitted smaller limit',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:20}});await p;assert.equal(c.state.outputs.items.length,2);p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=20/);assert.match(requests.at(-1).url,/cursor=outputs-token/);requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;assert.equal(c.state.outputs.items.length,4);c.dispose();});
|
|
|
|
test('real ApiClient response recovery retries the same failed output cursor at its smaller limit',async()=>{
|
|
const calls=[];let reads=0;
|
|
const api=createApiClient(async(url,init)=>{
|
|
calls.push({url,method:init.method});
|
|
const path=new URL(url,'http://localhost');
|
|
if(path.pathname.endsWith('/history'))return Response.json(windowPage());
|
|
if(!path.pathname.endsWith('/outputs'))return Response.json(detail());
|
|
reads++;
|
|
if(reads===1)return Response.json(outputs());
|
|
if(reads===2)return Response.json({success:false,history_version:1,code:'response-limit',error:'Reported outputs exceed the response budget',recovery:{action:'smaller-limit',limit:20}},{status:422});
|
|
return Response.json(outputs([receipt('2',0),receipt('2',1)],false));
|
|
});
|
|
const c=m.createHistoryController({api,positiveId:7});
|
|
try {
|
|
await c.load();await c.selectRun(id(1));await c.loadOutputs();
|
|
c.selectReceipt(receipt('1',1).occurrence);const retained=c.state.outputs.items;
|
|
await c.loadOutputs();
|
|
assert.deepEqual(c.state.outputs.error.recovery,{action:'smaller-limit',limit:20});
|
|
assert.equal(c.state.outputs.items,retained);assert.equal(c.state.selection.receipt.image_ordinal,1);
|
|
await c.retryOutputs();
|
|
const pages=calls.filter(call=>call.url.includes('/outputs?')).map(call=>new URL(call.url,'http://localhost'));
|
|
assert.deepEqual(pages.map(url=>url.searchParams.get('limit')),['40','40','20']);
|
|
assert.deepEqual(pages.map(url=>url.searchParams.get('cursor')),[null,'outputs-token','outputs-token']);
|
|
assert.deepEqual(c.state.outputs.items.map(item=>[item.occurrence.output_node_id,item.occurrence.image_ordinal]),[['1',0],['1',1],['2',0],['2',1]]);
|
|
assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(c.state.outputs.has_more,false);
|
|
assert.ok(calls.every(call=>call.method==='GET'));
|
|
} finally {c.dispose();}
|
|
});
|
|
|
|
test('history change clears every actionable object and revalidates parent independently',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'history-changed',recovery:{action:'refresh-history'}});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);assert.equal(c.state.run.detail,null);assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.actionsEnabled,false);assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.parent.detail.positive.id,7);assert.equal(c.state.refreshLabel,'Reload recorded history');c.dispose();});
|
|
|
|
test('capture and candidate changes clear candidates and reload output page one',async()=>{for(const code of ['capture-changed','candidate-stale']) {const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:409,code});await p;assert.equal(c.state.outputs.items.length,0);assert.equal(c.state.actionsEnabled,false);assert.doesNotMatch(requests.at(-1).url,/cursor=/);requests.at(-1).resolve({...outputs([],false),retained_occurrence_count:0});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.outputs.error,null);c.dispose();}});
|
|
|
|
test('reentry validates epoch before actions while restore accepts only harmless intent',async()=>{const {c,requests}=await loaded({search:'猫',scrollTop:120,mode:'effective',state:{window:windowPage()},pairId:9});assert.equal(c.state.intent.search,'猫');assert.equal(c.state.selection.mode,'effective');const p=c.revalidate();assert.equal(c.state.actionsEnabled,false);requests.at(-1).resolve({...windowPage(),history_epoch:id(4)});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();});
|
|
|
|
test('invalid caller identifiers cannot request history or select nonexistent contexts and receipts',async()=>{assert.equal(typeof m.createHistoryController,'function');for(const positiveId of [0,'7',NaN,9007199254740992]) assert.throws(()=>m.createHistoryController({positiveId,api:{}}),TypeError);const {c,requests}=await selected();await assert.rejects(c.selectRun('bad'),TypeError);assert.throws(()=>c.selectContext('missing'),TypeError);assert.throws(()=>c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0}),TypeError);assert.equal(requests.length,2);c.dispose();});
|
|
|
|
test('malformed output coordinates capture fingerprints evidence and continuations fail closed',async()=>{for(const mutate of [p=>p.items[0].occurrence.image_ordinal=256,p=>p.items[0].occurrence.run_id=id(2),p=>p.capture_fingerprint='bad',p=>p.has_more=false,p=>p.history_epoch=id(4),p=>p.items[0].file_identity='verified',p=>p.items[0].reference.root_key='bad',p=>p.items[0].current_candidate.url='/fake',p=>p.items[0].occurrence.output_node_id='\ud800']) {const {c,requests}=await selected();const p=c.loadOutputs(),data=outputs();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.outputs.error || c.state.refreshRequired);assert.equal(c.state.outputs.items.length,0);c.dispose();}});
|
|
|
|
test('restored identifiers select only matching freshly validated run and context',async()=>{const {c,requests}=setup({runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputs:outputs(),receipt:receipt().occurrence});assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.items.length,0);let p=c.load();requests[0].resolve(windowPage());await p;assert.equal(requests.length,2);assert.equal(c.state.run.loading,true);requests[1].resolve(detail());await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.selection.receipt,null);c.dispose();});
|
|
|
|
test('new run selection invalidates a pending reentry epoch check and its late error',async()=>{const {c,requests}=await selected();const validation=c.revalidate(),old=requests.at(-1),next=c.selectRun(id(2));assert.equal(old.signal.aborted,true);requests.at(-1).resolve(detail(2));await next;old.reject({status:409,code:'history-changed'});await validation;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.run.detail.run.run_id,id(2));assert.equal(c.state.refreshRequired,null);assert.equal(c.state.validating,false);c.dispose();});
|
|
|
|
test('explicit detail limit errors stay bounded read failures and retry only the detail',async()=>{const {c,requests}=await loaded();let p=c.selectRun(id(1),{anchorId:'2',contextId:digest('b')});requests.at(-1).reject({status:422,code:'response-limit'});await p;assert.equal(c.state.run.error.code,'response-limit');assert.equal(c.state.window.items.length,3);assert.equal(c.state.refreshRequired,null);p=c.retryRun();assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?epoch=/);requests.at(-1).resolve(detail());await p;assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.run.error,null);assert.equal(requests.length,3);c.dispose();});
|
|
|
|
test('repeated stale output responses stop automatic recovery and retain explicit reload state',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'candidate-stale'});await p;assert.equal(requests.length,4);requests.at(-1).reject({status:409,code:'candidate-stale'});await new Promise(resolve=>setImmediate(resolve));assert.equal(requests.length,4,'recovery must not poll indefinitely');assert.equal(c.state.refreshRequired,'candidate-stale');assert.equal(c.state.outputs.error.code,'candidate-stale');c.dispose();});
|
|
|
|
test('known and unknown frozen evidence cannot contradict their field or declared role provenance',async()=>{for(const mutate of [p=>p.contexts[0].evidence.role_provenance='recorded',p=>p.contexts[0].evidence.negative.raw.reason='missing',p=>p.contexts[0].evidence.positive.effective.reason=null,p=>p.run.outcome='conflicting',p=>p.run.reason='terminal_error']) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);c.dispose();}});
|
|
|
|
test('contradictory page counts and unrelated summary evidence are rejected before showing branches',async()=>{for(const mutate of [p=>{p.items=[];p.has_more=true;p.next_cursor='more';},p=>p.context_summaries[0].negative_raw.evidence[0].run_id=id(88),p=>p.context_summaries[0].positive_snapshot_id=digest('f')]){const {c,requests}=setup(),p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);c.dispose();}const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0)],false));await p;assert.ok(c.state.outputs.error);assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');c.dispose();});
|
|
|
|
function availableReceipt(currentScope,node='1',ordinal=0) {
|
|
const r=receipt(node,ordinal),locationKey=currentScope==='a'?digest('b'):digest('9');
|
|
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:digest(currentScope)}),base=`/prompt_manager/history/runs/${id(1)}/output/current`;
|
|
const media={kind:'reported-location',occurrence:r.occurrence,reference:r.reference,history_epoch:epoch,current_scope:digest(currentScope),location_key:locationKey,filename:'image.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
|
|
return {...r,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:digest(currentScope),location_key:locationKey,relative_path:'image.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media}};
|
|
}
|
|
|
|
test('every revalidation emission clears old available candidates and selected receipt before enabling actions',async()=>{
|
|
const {c,requests,changes}=await selected();let p=c.loadOutputs();
|
|
requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
|
c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(c.state.actionsEnabled,true);
|
|
const first=changes.length,validation=c.revalidate();assert.equal(c.state.validating,true);assert.equal(c.state.actionsEnabled,false);
|
|
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
|
const pending=requests.at(-1);assert.match(pending.url,/\/outputs\?/);assert.equal(c.state.outputs.loading,true);
|
|
const assertSafe=state=>{if(state.actionsEnabled){assert.equal(state.selection.receipt,null,'an enabled emission cannot retain the pre-validation receipt');assert.ok(state.outputs.items.every(r=>r.current_candidate.media.current_scope!==digest('a')),'an enabled emission cannot expose old current scope');}};
|
|
try {
|
|
changes.slice(first).forEach(assertSafe);
|
|
pending.resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await validation;
|
|
changes.slice(first).forEach(assertSafe);assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));
|
|
assert.equal(c.state.selection.receipt,null);assert.equal(c.state.validating,false);assert.equal(c.state.actionsEnabled,true);
|
|
} finally {c.dispose();}
|
|
});
|
|
|
|
test('pending mode-independent detail keeps the current mode and derives its exact effective roles without another GET',async()=>{
|
|
const {c,requests}=await loaded(),pending=c.selectRun(id(1)),read=requests.at(-1);
|
|
c.setMode('effective');assert.equal(read.signal.aborted,false);assert.equal(requests.length,2);
|
|
read.resolve(detail());await pending;
|
|
assert.equal(c.state.selection.mode,'effective');const ctx=c.state.run.detail.contexts.find(ctx=>ctx.anchor_id===c.state.selection.anchor_id);
|
|
const roles=m.frozenPromptRoles(ctx,c.state.selection.mode);assert.equal(roles.negative.text,'negative');assert.equal(roles.positive.text,null);assert.equal(roles.positive.status,'unresolved');assert.deepEqual(roles.positive.evidence,proof('unknown'));
|
|
assert.equal(requests.length,2);assert.equal(c.state.run.error,null);c.dispose();
|
|
});
|
|
|
|
test('active inspection history failure clears every emitted owning state for HTTP and stale-echo errors',async()=>{
|
|
for(const transport of ['http','echo']) {
|
|
const {c,requests,changes}=await selected();let pending=c.loadOutputs();
|
|
const item=availableReceipt('a');requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
|
const target=item.current_candidate.media,client=createApiClient(async()=>transport==='http'
|
|
? Response.json({success:false,code:'history-changed',error:'Recorded history changed',recovery:{action:'refresh-history'}},{status:409})
|
|
: Response.json({success:true,history_version:1,inspection_version:1,history_epoch:id(8)}));
|
|
const inspection=createInspectionController({api:client,adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
|
const remove=inspection.subscribe(state=>c.inspectionFailed?.(target,state)),first=changes.length;
|
|
try {
|
|
await inspection.select(target);
|
|
assert.equal(c.state.refreshRequired,'history-changed');
|
|
for(const state of changes.slice(first)) {
|
|
assert.equal(state.actionsEnabled,false);assert.deepEqual(state.window.items,[]);assert.equal(state.window.history_epoch,null);
|
|
assert.equal(state.run.detail,null);assert.equal(state.selection.run_id,null);assert.equal(state.selection.anchor_id,null);assert.equal(state.selection.context_id,null);assert.equal(state.selection.receipt,null);
|
|
assert.equal(state.window.next_cursor,null);assert.equal(state.outputs.next_cursor,null);assert.deepEqual(state.outputs.items,[]);
|
|
}
|
|
assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(c.state.parent.detail.positive.text,'saved');assert.equal(c.state.actionsEnabled,false);
|
|
} finally {remove();inspection.dispose();c.dispose();}
|
|
}
|
|
});
|
|
|
|
test('candidate and capture inspection failures keep their narrower recovery and obsolete targets cannot invalidate selection',async()=>{
|
|
for(const code of ['candidate-stale','capture-changed']) {
|
|
const {c,requests}=await selected();let pending=c.loadOutputs();const item=availableReceipt('a');
|
|
requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
|
c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code});
|
|
assert.equal(c.state.refreshRequired,code);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.run.detail.run.run_id,id(1));assert.equal(c.state.selection.receipt,null);assert.deepEqual(c.state.outputs.items,[]);
|
|
assert.match(requests.at(-1).url,/\/outputs\?/);requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
|
c.selectReceipt(item.occurrence);c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code:'history-changed'});
|
|
assert.equal(c.state.refreshRequired,null,'old candidate scope cannot invalidate the fresh receipt');assert.equal(c.state.selection.run_id,id(1));
|
|
c.dispose();
|
|
}
|
|
});
|
|
|
|
test('targeted unavailable receipt retry replaces only its bounded page and keeps all exact visit selections',async()=>{
|
|
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
|
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;
|
|
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt('2',1).occurrence);
|
|
const earlier=c.state.outputs.items.slice(0,2),readCount=requests.length;
|
|
p=c.retryReceipt?.(receipt('2',1).occurrence);
|
|
assert.equal(requests.length,readCount+1,'retry must read the successful page that owns this occurrence');
|
|
assert.match(requests.at(-1).url,/limit=40&cursor=outputs-token/);
|
|
requests.at(-1).resolve(outputs([receipt('2',0),availableReceipt('9','2',1)],false));await p;
|
|
assert.deepEqual(c.state.outputs.items.slice(0,2),earlier);assert.equal(c.state.outputs.items.length,4);
|
|
assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
|
assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.outputs.next_cursor,null);
|
|
const before=requests.length;await c.loadOutputs();assert.equal(requests.length,before,'availability retry cannot invent another page');c.dispose();
|
|
});
|
|
|
|
test('targeted retry retains the receipt on read failure and suppresses late availability after a newer selection',async()=>{
|
|
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt(receipt().occurrence);
|
|
p=c.retryReceipt?.(receipt().occurrence);assert.equal(requests.length,4);
|
|
requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
|
p=c.retryOutputs();const pending=requests.at(-1);c.selectReceipt(receipt('1',1).occurrence);assert.equal(pending.signal.aborted,true);
|
|
pending.resolve(outputs([availableReceipt('9'),receipt('1',1)]));await p;
|
|
assert.equal(c.state.outputs.items[0].current_candidate.status,'unavailable');assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();
|
|
});
|
|
|
|
test('targeted page retry honors a smaller budget and seeks only within that already loaded receipt page',async()=>{
|
|
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
|
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;c.selectReceipt(receipt('2',1).occurrence);
|
|
p=c.retryReceipt?.(receipt('2',1).occurrence);assert.equal(requests.length,5);
|
|
requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:1}});await p;
|
|
p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=1&cursor=outputs-token/);
|
|
requests.at(-1).resolve({...outputs([receipt('2',0)],true),next_cursor:'target-next'});await new Promise(resolve=>setImmediate(resolve));
|
|
assert.match(requests.at(-1).url,/limit=1&cursor=target-next/);requests.at(-1).resolve(outputs([availableReceipt('9','2',1)],false));await p;
|
|
assert.equal(c.state.outputs.items.length,4);assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(requests.length,7);c.dispose();
|
|
});
|
|
|
|
test('same-epoch refresh restores exact selected context mode and receipt only from fresh reads',async()=>{
|
|
const {c,requests,changes}=await selected();let p=c.loadOutputs();requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
|
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt().occurrence);const first=changes.length;
|
|
p=c.refresh();requests.at(-1).resolve(windowPage());await p;
|
|
assert.match(requests.at(-1).url,/\/history\/runs\//,'refresh must revalidate the selected run');assert.equal(c.state.actionsEnabled,false);
|
|
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
|
assert.match(requests.at(-1).url,/\/outputs\?/);assert.equal(c.state.actionsEnabled,false);
|
|
assert.ok(changes.slice(first).every(state=>!state.actionsEnabled),'old objects must never be briefly enabled');
|
|
requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
|
assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));assert.equal(c.state.actionsEnabled,true);c.dispose();
|
|
});
|
|
|
|
test('fresh exact group membership keeps an older selected run outside a refreshed newest prefix',async()=>{
|
|
for(const member of [true,false]) {
|
|
const {c,requests}=await selected();c.selectContext('2');c.setMode('effective');
|
|
const p=c.refresh();requests.at(-1).resolve({...windowPage([run(60),run(61)]),recorded_run_count:61});await p;
|
|
assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?/);
|
|
const fresh=detail();if(!member)fresh.contexts.forEach(context=>context.positive_raw_text_id=digest('f'));
|
|
requests.at(-1).resolve(fresh);await new Promise(resolve=>setImmediate(resolve));
|
|
assert.deepEqual(c.state.window.items.map(run=>run.run_id),[id(60),id(61)]);
|
|
if(member) {assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.actionsEnabled,true);}
|
|
else {assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);assert.ok(c.state.run.error);assert.equal(c.state.selection.run_id,null,'an incompatible saved selection must be discarded');}
|
|
c.dispose();
|
|
}
|
|
});
|
|
|
|
test('reentry restores exactly the prior bounded timeline pages before enabling the older selection',async()=>{
|
|
const restored={historyEpoch:epoch,resumeCursor:'old-upper-resume',loadedCount:80,runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputCount:4,receipt:receipt('2',1).occurrence,scrollLeft:731,scrollTop:95};
|
|
const {c,requests,changes}=setup(restored),p=c.load();assert.match(requests.at(-1).url,/resume=old-upper-resume/);
|
|
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/cursor=window-token/);
|
|
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(1+i))),recorded_run_count:80});await p;
|
|
assert.equal(c.state.window.items.length,80);assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/\/runs\//);
|
|
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
|
requests.at(-1).resolve(outputs([receipt('1',0),receipt('1',1),receipt('2',0),receipt('2',1)],false));await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(requests.length,4);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
|
assert.equal(c.state.intent.scrollLeft,731);assert.equal(c.state.intent.scrollTop,95);assert.equal(c.state.actionsEnabled,true);
|
|
assert.ok(changes.slice(0,-1).every(state=>!state.actionsEnabled));c.dispose();
|
|
});
|
|
|
|
test('refresh epoch change clears old selection before independent parent revalidation',async()=>{
|
|
const {c,requests}=await selected();let p=c.refresh();requests.at(-1).resolve({...windowPage(),history_epoch:id(8)});await p;
|
|
assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.selection.run_id,null);assert.equal(c.state.window.resume_cursor,null);assert.equal(c.state.run.detail,null);assert.deepEqual(c.state.outputs.items,[]);assert.equal(c.state.actionsEnabled,false);
|
|
requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();
|
|
});
|
|
|
|
test('obsolete inspection errors after another exact receipt and disposal cannot reach the owning history',async()=>{
|
|
const {c,requests,changes}=await selected();const first=availableReceipt('a'),second=availableReceipt('9','2',0);let p=c.loadOutputs();
|
|
requests.at(-1).resolve({...outputs([first,second],false),retained_occurrence_count:2});await p;
|
|
const pending=[];let target=first.current_candidate.media;
|
|
const inspection=createInspectionController({api:{get:()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))},adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
|
const remove=inspection.subscribe(state=>c.inspectionFailed(target,state));
|
|
c.selectReceipt(first.occurrence);p=inspection.select(target);
|
|
target=second.current_candidate.media;c.selectReceipt(second.occurrence);const next=inspection.select(target);
|
|
pending[1].reject({status:503,code:'storage-unavailable'});await next;const before=changes.length;
|
|
pending[0].reject({status:409,code:'history-changed'});await p;
|
|
assert.equal(changes.length,before);assert.equal(c.state.refreshRequired,null);assert.deepEqual(c.state.selection.receipt,second.occurrence);
|
|
p=inspection.retry();inspection.dispose();c.dispose();const count=changes.length;pending[2].reject({status:409,code:'history-changed'});await p;
|
|
assert.equal(changes.length,count);remove();
|
|
});
|
|
|
|
test('malformed resume and raw membership scalars cannot enable restored actions',async()=>{
|
|
for(const mutate of [page=>page.resume_cursor='',page=>page.resume_cursor='x'.repeat(4097),page=>page.resume_cursor=42,page=>page.resume_cursor=null]) {
|
|
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load(),page=windowPage();mutate(page);requests.at(-1).resolve(page);await p;
|
|
assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);assert.equal(c.state.run.detail,null);assert.equal(requests.length,1);c.dispose();
|
|
}
|
|
for(const value of [undefined,null,'bad',42]) {
|
|
const {c,requests}=await loaded(),p=c.selectRun(id(1)),fresh=detail();fresh.contexts[0].positive_raw_text_id=value;requests.at(-1).resolve(fresh);await p;
|
|
assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();
|
|
}
|
|
});
|
|
|
|
test('a resumed page success or error cannot replace a later run choice',async()=>{
|
|
for(const outcome of ['success','error']) {
|
|
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load();
|
|
requests[0].resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
|
const old=requests[1],next=c.selectRun(id(41));assert.equal(old.signal.aborted,true);requests[2].resolve(detail(41));await next;
|
|
if(outcome==='success')old.resolve({...windowPage(Array.from({length:40},(_,i)=>run(i+1))),recorded_run_count:80});else old.reject({status:409,code:'history-changed'});
|
|
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
|
}
|
|
});
|