* 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.
120 lines
9.1 KiB
JavaScript
120 lines
9.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const module = await import('../../web/js/workspace/viewer-state.mjs').catch(error => {
|
|
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
|
return {};
|
|
});
|
|
function session(options) {
|
|
assert.equal(typeof module.createViewerSession, 'function', 'viewer session contract is exported');
|
|
return module.createViewerSession(options);
|
|
}
|
|
const entries = () => Array.from({length: 161}, (_, index) => ({root_index: index % 2,
|
|
path: `folder/${index}.png`, filename: `${index}.png`, root_scope: 'a'.repeat(64),
|
|
thumbnail_url: `/prompt_manager/images/serve/thumbnails/${index}.png?root=${index % 2}&v=17&root_scope=${'a'.repeat(64)}`,
|
|
url: `/prompt_manager/images/serve/folder/${index}.png?root=${index % 2}&root_scope=${'a'.repeat(64)}`}));
|
|
|
|
test('viewer freezes loaded identities and wraps across unmounted entries', () => {
|
|
const items = entries(), updates = [];
|
|
const view = session({items, initialKey: '0:folder/0.png', onChange: (state, entry) => updates.push({state, entry})});
|
|
items[160].url = '/wrong-root'; items[160].thumbnail_url = '/wrong-version?v=18';items[160].root_scope = 'b'.repeat(64); items.reverse(); items.push(items[0]);
|
|
view.navigate(-1);
|
|
assert.deepEqual(view.state, {key:'0:folder/160.png',index:160,total:161,phase:'stopped',requested:false,mediaReady:false});
|
|
assert.equal(updates.at(-1).entry.url, `/prompt_manager/images/serve/folder/160.png?root=0&root_scope=${'a'.repeat(64)}`);
|
|
assert.equal(updates.at(-1).entry.root_scope, 'a'.repeat(64));
|
|
assert.equal(updates.at(-1).entry.thumbnail_url, `/prompt_manager/images/serve/thumbnails/160.png?root=0&v=17&root_scope=${'a'.repeat(64)}`);
|
|
assert.ok(Object.isFrozen(updates.at(-1).entry)); assert.ok(Object.isFrozen(view.state));
|
|
view.navigate(1); assert.equal(view.state.key, '0:folder/0.png');
|
|
view.navigate(1); assert.equal(view.state.key, '1:folder/1.png');
|
|
view.dispose();
|
|
});
|
|
|
|
test('viewer rejects empty duplicate or unknown identity', () => {
|
|
for (const [items, initialKey] of [[[], '0:a.png'], [[{path:'a.png'}], 'undefined:a.png'],
|
|
[[{root_index:0,path:''}], '0:'], [[{root_index:0,path:'a.png'},{root_index:0,path:'a.png'}], '0:a.png'],
|
|
[entries(), '0:missing.png']]) assert.throws(() => session({items,initialKey}), TypeError);
|
|
const view = session({items:entries(),initialKey:'0:folder/0.png'});
|
|
for (const direction of [0,2,-2,'1',null]) assert.throws(() => view.navigate(direction), TypeError);
|
|
assert.equal(view.state.index, 0); view.dispose();
|
|
});
|
|
|
|
test('viewer ignores stale readiness after navigation and disposal', () => {
|
|
let calls = 0;
|
|
const view = session({items:entries(),initialKey:'0:folder/0.png',onChange:() => calls++,
|
|
setTimer:() => assert.fail('Task4 must not start a timer'),clearTimer:() => assert.fail('No timer exists')});
|
|
view.ready('0:folder/0.png'); assert.equal(view.state.mediaReady,true);
|
|
view.navigate(1); view.failed('0:folder/0.png'); view.ready('0:folder/0.png');
|
|
assert.equal(view.state.mediaReady,false); assert.equal(view.state.phase,'stopped');
|
|
view.failed('1:folder/1.png'); assert.equal(view.state.phase,'error');
|
|
view.ready('1:folder/1.png'); assert.equal(view.state.phase,'stopped');
|
|
view.dispose(); const after = calls;
|
|
view.ready('1:folder/1.png'); view.failed('1:folder/1.png'); view.navigate(1); view.dispose();
|
|
assert.equal(calls,after); assert.equal(view.state.phase,'disposed'); assert.equal(view.state.requested,false);
|
|
});
|
|
|
|
function timedSession(items = entries().slice(0,3)) {
|
|
let now = 0, serial = 0;
|
|
const pending = new Map(), callbacks = [];
|
|
const view = session({items,initialKey:`${items[0].root_index}:${items[0].path}`,
|
|
setTimer: (callback, delay) => {const id=++serial;pending.set(id,{at:now+delay,callback});callbacks.push(callback);return id;},
|
|
clearTimer: id => pending.delete(id)});
|
|
const tick = ms => {now+=ms;for(const [id,timer] of [...pending])if(timer.at<=now){pending.delete(id);timer.callback();}};
|
|
return {view,pending,callbacks,tick};
|
|
}
|
|
|
|
test('slideshow schedules once only after current image ready', () => {
|
|
const {view,pending,tick}=timedSession();
|
|
view.play?.();assert.equal(view.state.phase,'waiting-image');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
|
|
view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');assert.equal(pending.size,1);
|
|
view.ready(view.state.key);view.play();assert.equal(pending.size,1);
|
|
tick(3499);assert.equal(view.state.index,0);tick(1);assert.equal(view.state.index,1);
|
|
assert.equal(view.state.phase,'waiting-image');assert.equal(pending.size,0);
|
|
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();assert.equal(pending.size,0);
|
|
});
|
|
|
|
test('slideshow pauses at video and resumes retained intent only after manual image navigation', () => {
|
|
const items=entries().slice(0,3);items[1].is_video=true;
|
|
const {view,pending,tick}=timedSession(items);
|
|
view.ready(view.state.key);view.play?.();tick(3500);
|
|
assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
|
|
view.ready(view.state.key);tick(7000);assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');
|
|
view.navigate(1);assert.equal(view.state.phase,'waiting-image');view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');
|
|
view.navigate(-1);view.stop();view.ready(view.state.key);view.navigate(1);view.ready(view.state.key);
|
|
assert.equal(view.state.phase,'stopped');assert.equal(pending.size,0);view.dispose();
|
|
});
|
|
|
|
test('stop cancels waiting and paused intent despite late callbacks', () => {
|
|
const {view,pending,callbacks}=timedSession();view.play?.();view.stop?.();view.ready(view.state.key);
|
|
assert.equal(view.state.requested,false);assert.equal(pending.size,0);
|
|
view.play?.();assert.equal(pending.size,1);const late=callbacks.at(-1);
|
|
view.stop();late();view.ready(view.state.key);assert.equal(view.state.index,0);assert.equal(view.state.phase,'stopped');
|
|
view.play();view.navigate(1);late();assert.equal(view.state.index,1);assert.equal(view.state.phase,'waiting-image');
|
|
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();
|
|
});
|
|
|
|
test('hidden interaction error and disposal never resume', () => {
|
|
const {view,pending,callbacks}=timedSession();view.ready(view.state.key);view.play?.();
|
|
assert.equal(pending.size,1);view.stop();for(const callback of callbacks)callback();view.ready(view.state.key);
|
|
assert.equal(view.state.phase,'stopped');view.play();view.failed(view.state.key);view.ready(view.state.key);
|
|
assert.equal(view.state.phase,'stopped');assert.equal(view.state.requested,false);assert.equal(pending.size,0);
|
|
view.play();view.dispose();const disposed=view.state;
|
|
for(const callback of callbacks)callback();view.play();view.stop();view.ready(view.state.key);view.failed(view.state.key);view.navigate(1);view.dispose();
|
|
assert.equal(view.state,disposed);assert.equal(pending.size,0);
|
|
});
|
|
|
|
test('slideshow cannot start on video one entry or media error', () => {
|
|
for(const items of [[entries()[0]],[{...entries()[0],is_video:true},entries()[1]]]) {
|
|
const {view,pending}=timedSession(items);view.play?.();assert.equal(view.state.requested,false);assert.equal(pending.size,0);view.dispose();
|
|
}
|
|
const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose();
|
|
});
|
|
|
|
test('explicit occurrence session retains video pause and timer invalidation with location keys',async()=>{
|
|
const history=await import('../../web/js/workspace/history-media.mjs');
|
|
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
|
const items=['c','d'].map((key,index)=>{const query=new URLSearchParams({node:String(index),ordinal:'0',epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;return {kind:'reported-location',occurrence:{run_id:run,output_node_id:String(index),image_ordinal:0},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},history_epoch:epoch,current_scope:scope,location_key:key.repeat(64),filename:'a.png',is_video:index===1,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};});
|
|
const timers=[],pending=new Set();let callbacks=0;
|
|
const view=session({adapter:history.reportedLocationAdapter,items,initialKey:'c'.repeat(64),onChange:()=>callbacks++,setTimer:fn=>{timers.push(fn);pending.add(fn);return fn;},clearTimer:fn=>pending.delete(fn)});
|
|
view.play();view.ready('c'.repeat(64));assert.equal(pending.size,1);timers[0]();assert.equal(view.state.key,'d'.repeat(64));assert.equal(view.state.phase,'paused-video');view.stop();view.navigate(-1);view.ready('c'.repeat(64));assert.equal(view.state.requested,false);view.dispose();const count=callbacks;timers[0]();view.ready('d'.repeat(64));assert.equal(callbacks,count);
|
|
});
|