feat(history): add chronological runs and exact output inspection (#227)

* 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.
This commit is contained in:
Vito
2026-10-18 17:17:05 -07:00
committed by GitHub
parent 10197026be
commit ecd21165a7
54 changed files with 9929 additions and 232 deletions
@@ -0,0 +1,219 @@
import { writeFile } from 'node:fs/promises';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
// Content, row counts, and image bytes come from independent synthetic datasets.
// Saved choices and actual executions are separate production sections. The concept's
// illustrative repeat dialog is compared with Recorded run for native behavior;
// frozen evidence and current-file/unknown copy intentionally describe actual data.
// Parent acceptance scopes exact comparisons to history geometry/roles/native state.
// No content is masked and the immutable concept is never rewritten for comparison.
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
remainingApplicationPolish: [
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
'Shared Viewer stage background and toolbar alignment remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
export async function captureCanonicalState(page, testInfo, name, selector) {
await page.evaluate(async () => {
await document.fonts.ready;
for (const image of document.images) image.loading = 'eager';
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
});
const target = selector ? page.locator(selector).first() : page;
const path = testInfo.outputPath(`${name}.png`);
await target.screenshot({ path, animations: 'disabled' });
await testInfo.attach(name, { path, contentType: 'image/png' });
}
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-block',
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
return [key, nodes.map(node => {
const style = getComputedStyle(node), before = getComputedStyle(node, '::before');
const box = node.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height,
padding: style.padding, top: style.top, left: style.left, background: style.backgroundColor, color: style.color,
border: style.borderTopWidth, borderColor: style.borderTopColor, radius: style.borderRadius,
fontSize: style.fontSize, fontFamily: style.fontFamily, lineHeight: style.lineHeight,
fontWeight: style.fontWeight, text: node.textContent, contextId: node.dataset.contextId || node.dataset.contextBranch,
runId: node.dataset.runId,
marginLeft: style.marginLeft, outline: style.outlineWidth, outlineColor: style.outlineColor,
outlineOffset: style.outlineOffset, before: { width: before.width, height: before.height,
top: before.top, left: before.left, borderLeft: before.borderLeftWidth,
borderBottom: before.borderBottomWidth, radius: before.borderBottomLeftRadius,
background: before.backgroundColor, color: before.color, borderColor: before.borderTopColor,
borderLeftColor: before.borderLeftColor, borderBottomColor: before.borderBottomColor,
fontSize: before.fontSize, fontFamily: before.fontFamily, fontWeight: before.fontWeight,
content: before.content } };
})];
})) }), selectors);
}
export async function nativeDialogState(locator) {
return locator.evaluate(node => {
const style = getComputedStyle(node), box = node.getBoundingClientRect();
return { modal: node.matches(':modal'), focusInside: node.contains(document.activeElement),
background: style.backgroundColor, color: style.color, borderColor: style.borderTopColor,
border: style.borderTopWidth, width: box.width, height: box.height,
viewportWidth: innerWidth, viewportHeight: innerHeight };
});
}
export async function exerciseCurrentPairSend(page, roles) {
const host = await page.context().newPage();
await host.goto('/prompt_manager/workspace#/overview');
try {
await host.evaluate(async () => {
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target = { id: 'canonical-pair', label: 'Canonical destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
window.canonicalApplied = [];
window.canonicalHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Canonical ComfyUI', adapter: {
listTargets: () => [target],
preview: (_id, roles) => ({ preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: '', after })),
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: '', after: roles.positive } }, snapshot: roles }),
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
} });
});
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(() => window.canonicalApplied)).toEqual([roles]);
await page.keyboard.press('Escape');await expect(send).toBeFocused();
} finally {await host.evaluate(() => window.canonicalHost?.dispose());await host.close();}
}
export async function compareCanonicalHistory(reference, actual, testInfo) {
const path = testInfo.outputPath('canonical-history-geometry.json');
await writeFile(path, JSON.stringify({ reference, actual, comparisonScope: canonicalDifferences }, null, 2));
await testInfo.attach('canonical-history-geometry', { path, contentType: 'application/json' });
for (const geometry of [reference, actual]) {
expect.soft(geometry.role).toHaveLength(2);
expect.soft(geometry.prompt).toHaveLength(2);
expect.soft(geometry.heading[0].fontSize).toBe('14px');
expect.soft(geometry.intro[0].fontSize).toBe('12px');
expect.soft(geometry.footer).toHaveLength(1);
expect.soft(geometry.positiveLabel).toHaveLength(1);
expect.soft(geometry.positiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(geometry.surface[0].padding).toBe('22px');
expect.soft(geometry.surface[0].radius).toBe('7px');
expect.soft(geometry.timeline[0].padding).toBe('18px 20px');
expect.soft(geometry.positive[0].border).toBe('2px');
for (const [index, branch] of geometry.branch.entries()) {
expect.soft(branch.height).toBe(57);
expect.soft(branch.border).toBe('2px');
expect.soft(branch.marginLeft).toBe(`${38 + index * 47}px`);
expect.soft(branch.before).toMatchObject({ width: '30px', height: '25px', top: '-25px', left: '-30px', borderLeft: '2px', borderBottom: '2px', radius: '22px' });
}
}
expect.soft(actual.recordedPositiveLabel).toHaveLength(1);
expect.soft(actual.recordedPositiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(actual.recordedHeading[0].fontSize).toBe(reference.heading[0].fontSize);
expect.soft(actual.recordedCanvas[0].width).toBeGreaterThanOrEqual(actual.recordedTimeline[0].width - 42);
for (const geometry of [reference, actual]) {
expect.soft(geometry.runDialog).toMatchObject({ modal: true, focusInside: true, border: '1px' });
expect.soft(geometry.runDialog.width).toBeLessThanOrEqual(geometry.runDialog.viewportWidth);
expect.soft(geometry.runDialog.height).toBeLessThanOrEqual(geometry.runDialog.viewportHeight);
}
for (const property of ['background', 'color', 'borderColor']) expect.soft(actual.runDialog[property]).toBe(reference.runDialog[property]);
for (const [key, properties] of [
['surface', ['padding', 'background', 'border', 'borderColor', 'radius']],
['timeline', ['padding', 'background', 'border', 'borderColor', 'radius']],
['positive', ['border', 'borderColor']],
['positiveLabel', ['color', 'fontSize', 'top', 'left']],
['branch', ['height', 'border', 'borderColor', 'fontSize']],
['label', ['color', 'fontSize']],
['dot', ['background', 'borderColor', 'radius', 'outline', 'outlineColor', 'outlineOffset', 'color', 'fontSize']],
]) {
for (const property of properties) expect.soft(actual[key][0]?.[property], `${key}.${property}`).toBe(reference[key][0][property]);
}
for (const property of ['color', 'background']) expect.soft(actual.positive[0].before[property]).toBe(reference.positive[0].before[property]);
for (const [index, block] of actual.role.entries()) for (const property of ['border', 'borderColor', 'fontSize']) expect.soft(block[property]).toBe(reference.role[index][property]);
for (const [index, prompt] of actual.prompt.entries()) for (const property of ['fontSize', 'lineHeight', 'fontFamily']) expect.soft(prompt[property]).toBe(reference.prompt[index][property]);
assertRecordedHistory(reference, actual, { branchCount: 1, anchorCount: 1, repeatCount: 0 });
}
export function assertRecordedHistory(reference, actual, { branchCount, anchorCount, repeatCount }) {
expect(branchCount).toBeGreaterThan(0);
expect(reference.branch.length).toBeGreaterThanOrEqual(2);
expect(reference.dot).toHaveLength(1);
expect(reference.repeat.length).toBeGreaterThan(0);
expect.soft(actual.recordedBranch, 'chronological branches').toHaveLength(branchCount);
expect.soft(actual.recordedLabel, 'chronological labels').toHaveLength(branchCount);
expect.soft(actual.recordedAnchor, 'chronological anchors').toHaveLength(anchorCount);
expect.soft(actual.recordedRepeat, 'chronological repeats').toHaveLength(repeatCount);
expect.soft(actual.recordedMarker).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedColumn).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedPositive).toHaveLength(1);
for (const property of ['border', 'borderColor']) expect.soft(actual.recordedPositive[0][property], `recorded positive ${property}`).toBe(reference.positive[0][property]);
for (const property of ['color', 'background', 'width', 'height', 'fontSize']) expect.soft(actual.recordedPositive[0].before[property], `recorded positive glyph ${property}`).toBe(reference.positive[0].before[property]);
for (const [index, branch] of actual.recordedBranch.entries()) {
for (const property of ['height', 'border', 'borderColor']) expect.soft(branch[property], `recorded branch ${index} ${property}`).toBe(reference.branch[0][property]);
for (const property of ['width', 'height', 'top', 'left', 'borderLeft', 'borderBottom', 'radius', 'borderLeftColor', 'borderBottomColor']) expect.soft(branch.before[property], `recorded curve ${index} ${property}`).toBe(reference.branch[0].before[property]);
if (index) expect.soft(branch.y - actual.recordedBranch[index - 1].y, 'recorded 57px branch rhythm').toBe(reference.branch[1].y - reference.branch[0].y);
}
for (const label of actual.recordedLabel) for (const property of ['color', 'fontSize']) expect.soft(label[property], `recorded label ${property}`).toBe(reference.label[0][property]);
for (const [index, column] of actual.recordedColumn.entries()) {
expect.soft(column.left).toBe(`${38 + index * 64}px`);
if (index) expect.soft(column.x - actual.recordedColumn[index - 1].x, '64px chronological columns').toBe(64);
}
const checkMarker = (marker, canonical, anchor) => {
const visual = actual.pointerCoarse ? marker.before : marker;
const size = anchor ? 29 : 12;
expect.soft(visual.width, 'recorded marker visual width').toBe(actual.pointerCoarse ? `${size}px` : size);
expect.soft(visual.height, 'recorded marker visual height').toBe(actual.pointerCoarse ? `${size}px` : size);
for (const property of ['background', 'borderColor', 'radius']) expect.soft(visual[property], `recorded marker ${property}`).toBe(canonical[property]);
if (actual.pointerCoarse) {
expect.soft(marker.width, 'coarse marker hit width').toBeGreaterThanOrEqual(44);
expect.soft(marker.height, 'coarse marker hit height').toBeGreaterThanOrEqual(44);
expect.soft(visual.borderLeft).toBe('2px');
} else expect.soft(marker.border).toBe(canonical.border);
if (anchor) {
expect.soft(marker.text).toBe('−');
if (actual.pointerCoarse) expect.soft(visual.content).toBe('"−"');
for (const property of ['color', 'fontSize', 'fontFamily', 'fontWeight']) expect.soft(visual[property], `recorded minus ${property}`).toBe(canonical[property]);
const branch = actual.recordedBranch.find(value => value.contextId === marker.contextId);
expect(branch, 'each context anchor has its real branch').toBeTruthy();
expect.soft(Math.abs(marker.x + marker.width / 2 - branch.x)).toBeLessThanOrEqual(1);
expect.soft(Math.abs(marker.y + marker.height / 2 - branch.y)).toBeLessThanOrEqual(1);
} else expect.soft(marker.text).toBe('');
};
for (const marker of actual.recordedAnchor) checkMarker(marker, reference.dot[0], true);
for (const marker of actual.recordedRepeat) checkMarker(marker, reference.repeat[0], false);
}
+75
View File
@@ -16,6 +16,25 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_visual_fixture import seed_workspace_visual
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
from tests.browser.workspace_history_fixture import (
capture_workspace_history_zero_outputs,
register_workspace_history_commands,
seed_workspace_history,
)
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
"/__test__/canonical/workspace": (
"docs/concepts/workspace/index.html",
"text/html",
),
"/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"),
"/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"),
"/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"),
"/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"),
"/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"),
"/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"),
}
class TestModel:
@@ -80,6 +99,18 @@ def create_app(root: Path) -> web.Application:
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
history_commands = register_workspace_history_commands(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
return web.Response(
body=(CANONICAL_ROOT / filename).read_bytes(),
content_type=content_type,
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)
for path in CANONICAL_ASSETS:
routes.get(path)(canonical_asset)
def seed(
extra_images: int = 0,
@@ -223,9 +254,50 @@ def create_app(root: Path) -> web.Application:
api.db.link_image_to_prompt(video_prompt_id, str(video))
api.invalidate_gallery_cache()
history_fixture = None
@routes.post("/__test__/history/capture-zero-outputs")
async def capture_history_outputs(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
if (
not isinstance(data, dict)
or set(data) != {"run_id"}
or history_fixture is None
or data["run_id"] not in history_fixture["runs"].values()
):
raise web.HTTPBadRequest()
captured = await api._run_in_executor(
capture_workspace_history_zero_outputs, api, data["run_id"]
)
return web.json_response({"success": captured})
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict):
raise web.HTTPBadRequest()
chronological_history = data.get("chronological_history", False)
history_runs = data.get("history_runs", 0)
if (
not isinstance(chronological_history, bool)
or isinstance(history_runs, bool)
or not isinstance(history_runs, int)
or not 0 <= history_runs <= 1200
or (not chronological_history and "history_runs" in data)
or (
chronological_history
and set(data) - {"chronological_history", "history_runs"}
)
):
raise web.HTTPBadRequest()
if chronological_history:
manifest = await api._run_in_executor(
lambda: seed_workspace_history(api, root, history_runs=history_runs)
)
history_fixture = manifest
return web.json_response({"success": True, **manifest})
history_commands.reset()
workspace_visual = data.get("workspace_visual", False)
full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
@@ -277,15 +349,18 @@ def create_app(root: Path) -> web.Application:
):
raise web.HTTPBadRequest()
if workspace_visual:
history_fixture = None
await api._run_in_executor(seed_workspace_visual, api, root)
return web.json_response({"success": True})
if full_inspector:
history_fixture = None
await api._run_in_executor(
lambda: seed_workspace_inspector(
api, root, extra_images=extra, include_video=include_video
)
)
return web.json_response({"success": True})
history_fixture = None
await api._run_in_executor(
seed,
extra,
+661
View File
@@ -0,0 +1,661 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const recorded=page=>page.getByRole('region',{name:'Recorded executions',exact:true});
const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
const exact=async locator=>locator.evaluate(node=>node.textContent);
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});await expect(reset).toBeOK();page.fixture=await reset.json();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.historyErrors).toEqual([]);});
const command=async(request,data)=>{const response=await request.post('/__test__/history/commands',{data});await expect(response).toBeOK();return response.json();};
const restoreSnapshot=async(request,bytes)=>{const response=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'history.db',mimeType:'application/octet-stream',buffer:bytes}}});await expect(response).toBeOK();expect((await response.json()).success).toBe(true);};
const historyRead=async(request,id)=>{const response=await request.get(`/prompt_manager/library/${id}/history`);await expect(response).toBeOK();return response.json();};
const frozenContexts=contexts=>contexts.map(({navigation,positive_prompt_id,negative_prompt_id,pair_id,...frozen})=>frozen);
const immutableReceipts=items=>items.map(({current_candidate,...receipt})=>receipt);
async function reviewCapture(page,testInfo,name,target) {
await target.scrollIntoViewIfNeeded();await expect(target).toBeVisible();await page.evaluate(()=>document.fonts.ready);
const path=testInfo.outputPath(`${name}.png`);await target.screenshot({path,animations:'disabled'});await testInfo.attach(name,{path,contentType:'image/png'});
const geometry=await target.evaluate(node=>{
const box=node.getBoundingClientRect();
return {bounds:{x:box.x,y:box.y,width:box.width,height:box.height},scrollLeft:node.scrollLeft,scrollTop:node.scrollTop,
buttons:[...node.querySelectorAll('button')].filter(button=>button.checkVisibility()).map(button=>{const box=button.getBoundingClientRect();return {label:button.getAttribute('aria-label')||button.textContent,disabled:button.disabled,pressed:button.getAttribute('aria-pressed'),width:box.width,height:box.height};})};
});
const geometryPath=testInfo.outputPath(`${name}-geometry.json`);await writeFile(geometryPath,JSON.stringify(geometry,null,2));
await testInfo.attach(`${name}-geometry`,{path:geometryPath,contentType:'application/json'});
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
test('owned history commands validate fixed boundaries and expire after ordinary reset',async({request})=>{
for(const data of [{}, {action:'arm',boundary:'arbitrary'}, {action:'replace-file',path:'/tmp/other'}, {action:'release',extra:true}, {action:'restore'}]) expect((await request.post('/__test__/history/commands',{data})).status()).toBe(400);
await command(request,{action:'arm',boundary:'window'});await command(request,{action:'release'});
await expect(await request.post('/__test__/reset')).toBeOK();
expect((await request.post('/__test__/history/commands',{data:{action:'replace-file'}})).status()).toBe(409);
expect((await request.get('/__test__/history/snapshot')).status()).toBe(409);
});
for(const boundary of ['window','detail','candidate','inspection','media']) test(`real restore rejects in-flight ${boundary} publication and all old epoch URLs`,async({request,page})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),epoch=window.history_epoch;
const base=`/prompt_manager/history/runs/${f.runs.shared}`;
const detail=await(await request.get(`${base}?epoch=${epoch}`)).json();
const firstWindow=await(await request.get(`/prompt_manager/library/${f.detail_id}/history?limit=1`)).json();expect(firstWindow.next_cursor).toBeTruthy();
const outputs=await(await request.get(`${base}/outputs?epoch=${epoch}&limit=1`)).json();
const allOutputs=await(await request.get(`${base}/outputs?epoch=${epoch}`)).json();
const media=outputs.items[0].current_candidate.media;
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
const bytes=await snapshot.body();
const target={window:`/prompt_manager/library/${f.detail_id}/history?limit=1`,detail:`${base}?epoch=${epoch}`,candidate:`${base}/outputs?epoch=${epoch}`,inspection:media.inspection_url,media:media.url}[boundary];
await command(request,{action:'arm',boundary});const pending=request.get(target);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await restoreSnapshot(request,bytes);
} finally {await command(request,{action:'release'});}
const retired=await pending;expect(retired.status()).toBe(409);expect((await retired.json()).code).toBe('history-changed');
for(const url of [`/prompt_manager/library/${f.detail_id}/history?limit=1&cursor=${encodeURIComponent(firstWindow.next_cursor)}`,`${base}?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}&cursor=${encodeURIComponent(outputs.next_cursor)}`,media.url,media.thumbnail_url,media.inspection_url]) {
const response=await request.get(url);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
}
const fresh=await historyRead(request,f.detail_id);expect(fresh.history_epoch).not.toBe(epoch);
const after=await(await request.get(`${base}?epoch=${fresh.history_epoch}`)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(detail.contexts));
for(const context of after.contexts) {expect(context.positive_prompt_id).toBeNull();expect(context.navigation).toEqual({positive_available:false,negative_available:false,pair_available:false});}
const freshOutputs=await(await request.get(`${base}/outputs?epoch=${fresh.history_epoch}`)).json();
expect(immutableReceipts(freshOutputs.items)).toEqual(immutableReceipts(allOutputs.items));
expect(freshOutputs.items).toHaveLength(5);await expect(await request.get(`/prompt_manager/library/${f.detail_id}`)).toBeOK();
});
test('same-path replacement is current content and a during-inspection change is rejected',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
const original=await request.get(media.url);expect(original.headers()['cache-control']).toBe('no-store');const bytes=await original.body();
await command(request,{action:'arm',boundary:'inspection'});const pending=request.get(media.inspection_url);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await command(request,{action:'replace-file'});
} finally {await command(request,{action:'release'});}
const changed=await pending;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('candidate-stale');
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
const current=await request.get(after.items[0].current_candidate.media.url);await expect(current).toBeOK();expect(await current.body()).not.toEqual(bytes);
for(const field of ['url','thumbnail_url','inspection_url'])expect((await request.get(after.items[0].current_candidate.media[field])).headers()['cache-control']).toBe('no-store');
expect(after.items[0]).toMatchObject({file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown'});
});
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await command(request,{action:'reconfigure-root'});
} finally {await command(request,{action:'release'});}
expect((await pending).status()).toBe(409);
for(const field of ['url','thumbnail_url','inspection_url']) {const response=await request.get(media[field]);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('candidate-stale');}
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
expect(after.items[0].current_candidate).toMatchObject({status:'unavailable',reason:'namespace_mismatch'});
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('namespace_mismatch');
await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
});
test('archive delete merge and prune preserve frozen evidence without repairing original saved hints',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`;
const before=await(await request.get(base)).json(),outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
await expect(await request.patch(`/prompt_manager/pairs/${f.saved_pair}`,{data:{archived:true}})).toBeOK();
expect(frozenContexts((await(await request.get(base)).json()).contexts)).toEqual(frozenContexts(before.contexts));
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
for(const pair of details.variants)await expect(await request.delete(`/prompt_manager/pairs/${pair.id}`)).toBeOK();
const text=details.positive.text;
expect(f.merge_duplicate_id).not.toBe(f.detail_id);
const merge=await request.post('/prompt_manager/cleanup');await expect(merge).toBeOK();expect((await merge.json()).duplicates_removed).toBeGreaterThan(0);
await expect(await request.delete(`/prompt_manager/delete/${f.detail_id}`)).toBeOK();
await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:text,negative_text:'replacement saved negative'}})).toBeOK();
const prune=await request.post('/prompt_manager/maintenance',{data:{operations:['prune_orphaned_prompts']}});await expect(prune).toBeOK();expect((await prune.json()).all_successful).toBe(true);
const after=await(await request.get(base)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(before.contexts));
for(const context of after.contexts) {expect(context.navigation.positive_available).toBe(false);expect(context.positive_prompt_id).toBeNull();}
const receipts=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(receipts.items)).toEqual(immutableReceipts(outputs.items));
});
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
});
for(const outcome of ['success','error'])test(`delayed actual old browser ${outcome} cannot repaint after restore and route return`,async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const snapshot=await request.get('/__test__/history/snapshot');const bytes=await snapshot.body();
let entered,release;const started=new Promise(resolve=>{entered=resolve;}),held=new Promise(resolve=>{release=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{
if(outcome==='error')await restoreSnapshot(request,bytes);
const response=await route.fetch();expect(response.status()).toBe(outcome==='success'?200:409);entered();await held;
await route.fulfill({response}).catch(()=>{});
});
try {
await marker(page,f.runs.shared).click();await started;if(outcome==='success')await restoreSnapshot(request,bytes);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');release();
await page.unroute(new RegExp(`/history/runs/${f.runs.shared}\\?`));await open(page,f.detail_id);
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await recorded(page).getByRole('button',{name:'Reload recorded history',exact:true}).click();
await expect(recorded(page).locator('button[data-run-id]').first()).toBeEnabled();await expect(page.locator('dialog[open]')).toHaveCount(0);
await marker(page,f.runs.empty).click();await expect(dialog(page)).toContainText(f.runs.empty);await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();
} finally {release();}
});
test('strict reset seeds real A,A,B positions, two contexts and independently selected saved gallery',async({page,request})=>{
for(const data of [{chronological_history:'true'},{chronological_history:true,native_library:true},{chronological_history:true,full_inspector:false},{chronological_history:true,extra_images:0},{chronological_history:true,history_runs:true},{chronological_history:true,unknown:1},{history_runs:1}])
expect((await request.post('/__test__/reset',{data})).status()).toBe(400);
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
await open(page,fixture.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
await expect(recorded(page)).toContainText('same positive raw text');
await expect(recorded(page).locator('[data-context-branch]')).toHaveCount(2);
const buttons=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button');
await expect(buttons).toHaveCount(3);
expect(await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.runId))).toEqual(fixture.aab);
const contexts=await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.contextId));expect(contexts[0]).toBe(contexts[1]);expect(contexts[2]).not.toBe(contexts[0]);
for(const [index,id] of fixture.aab.entries()) {
await marker(page,id).focus();await page.keyboard.press(index===1?' ':'Enter');
await expect(dialog(page)).toContainText(id);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(marker(page,id)).toBeFocused();
}
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
});
test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode and equal-looking snapshots',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
const same=window.context_summaries.filter(c=>c.negative_raw.preview?.startsWith('same visible negative'));
expect(same.length).toBe(2);expect(same[0].context_id).not.toBe(same[1].context_id);
await expect(recorded(page).locator(`[data-context-branch="${same[0].context_id}"]`)).toHaveCount(1);
const shared=window.items.find(run=>run.run_id===f.runs.shared);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page)).toBeVisible();
const contexts=dialog(page).getByRole('group',{name:'Recorded anchors',exact:true});
await expect(contexts.getByRole('button')).toHaveCount(3);await expect(contexts.locator('[aria-pressed="true"]')).toContainText('z');
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
});
test('every ordered receipt remains selected through distinct current locations, Viewer and nested Escape',async({page,request})=>{
const f=page.fixture;let outputReads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))outputReads++;});await open(page,f.detail_id);await marker(page,f.runs.shared).click();
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const receipts=dialog(page).locator('[data-receipt]');
expect(await receipts.evaluateAll(nodes=>nodes.map(node=>[node.dataset.outputNodeId,Number(node.dataset.imageOrdinal)]))).toEqual([['10',0],['10',1],['2',0],['2',1],['z',0]]);
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
await expect(dialog(page)).toContainText('Context membership unknown');
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
await receipts.nth(1).getByRole('button',{name:'View current file',exact:true}).click();await page.keyboard.press('Escape');
await expect(receipts.nth(1)).toHaveAttribute('aria-current','true');
expect(outputReads).toBe(loadedReads);
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);await expect(marker(page,f.runs.shared)).toBeFocused();
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
const outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(outputs.items).toHaveLength(5);
});
test('inspection first discovers a real restore and immediately retires every surrounding recorded action',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
await command(request,{action:'arm',boundary:'inspection'});
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
try {
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await page.evaluate(()=>{
window.recordedRecoveryStates=[];
const run=document.querySelector('.recorded-run-dialog');let changed=false;
const observer=new MutationObserver(()=>{
changed ||= run.querySelector('.recorded-run-status').textContent.includes('Recorded evidence changed');
if(changed)window.recordedRecoveryStates.push({receipts:run.querySelectorAll('[data-receipt]').length,active:[...run.querySelectorAll('[data-send-role],[data-copy-role],[data-view-receipt]')].filter(node=>!node.disabled&&node.checkVisibility()).length});
});observer.observe(run,{subtree:true,childList:true,attributes:true,characterData:true});
});
await restoreSnapshot(request,await snapshot.body());
} finally {await command(request,{action:'release'});}
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
if(await viewer.count())await page.keyboard.press('Escape');
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toContainText('revalidated');
const states=await page.evaluate(()=>window.recordedRecoveryStates);expect(states.length).toBeGreaterThan(0);expect(states.every(state=>state.receipts===0&&state.active===0)).toBe(true);
await expect(dialog(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
});
test('active stale inspection echo reaches history ownership without a second metadata implementation',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await page.route(/\/output\/current\/inspection\?/,async route=>{
const response=await route.fetch(),body=await response.json();body.history_epoch='33333333-3333-3333-3333-333333333333';await route.fulfill({response,json:body});
});
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
});
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
let writes=0,galleryReads=0,outputReads=0;
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await expect(missing).toContainText('missing');
await missing.getByRole('button',{name:'Select reported output',exact:true}).click();await expect(missing).toHaveAttribute('aria-current','true');
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeHidden();
await command(request,{action:'make-missing-available'});
const beforeReads=outputReads,beforeGallery=galleryReads;
await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
await expect(missing).toContainText('Current candidate: available');await expect(missing).toHaveAttribute('aria-current','true');
expect(outputReads).toBe(beforeReads+1);expect(galleryReads).toBe(beforeGallery);expect(writes).toBe(0);
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
});
test('marker labels disclose exact observed dequeue and outcome including times outside display range',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const window=await historyRead(request,f.detail_id);
for(const run of window.items)for(const anchor of [...new Map(run.matching_anchors.map(anchor=>[anchor.context_id,run.matching_anchors.find(first=>first.context_id===anchor.context_id)])).values()]) {
const button=recorded(page).locator(`button[data-run-id="${run.run_id}"][data-context-id="${anchor.context_id}"]`);
const label=await button.getAttribute('aria-label');
expect(label).toContain(`Observed dequeue ${run.observed_dequeue_at} ms`);expect(label).toContain(`Outcome ${run.outcome}`);expect(label).toContain(`anchor ${anchor.anchor_id}`);
expect(await button.getAttribute('title')).toBe(label);
if(run.observed_dequeue_at==='9223372036854775807')expect(label).toContain('outside display range');
}
});
test('same-epoch refresh retains recorded context mode and exact receipt after fresh candidate reads',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const selected=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await selected.getByRole('button',{name:'Select reported output',exact:true}).click();
await page.keyboard.press('Escape');
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))reads++;});
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();
await expect(marker(page,f.runs.shared,'z')).toHaveAttribute('aria-pressed','true');await expect.poll(()=>reads).toBe(1);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await expect(selected).toHaveAttribute('aria-current','true');await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
});
test('route reentry restores older timeline range and both scroll axes after 61 new same-epoch runs',async({page,request})=>{
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const f=await reset.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});await open(page,f.positive_id);
await expect(recorded(page)).toContainText('40 loaded positions');await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
const oldIds=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun));const oldRun=oldIds[0];
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const scroll=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(scroll.left).toBeGreaterThan(0);expect(scroll.top).toBeGreaterThan(0);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
let windowReads=0;page.on('request',request=>{if(/\/library\/\d+\/history$/.test(new URL(request.url()).pathname))windowReads++;});
let release,entered;const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
await page.route(new RegExp(`/history/runs/${oldRun}\\?`),async route=>{const response=await route.fetch();entered();await held;await route.fulfill({response}).catch(()=>{});});
try {
await page.evaluate(id=>{location.hash=`#/library/${id}`;},f.positive_id);await started;
await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','true');
} finally {release();}
await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(oldIds);expect(windowReads).toBe(2);
const restored=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(restored.left-scroll.left)).toBeLessThanOrEqual(1);expect(Math.abs(restored.top-scroll.top)).toBeLessThanOrEqual(1);
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText('149 distinct recorded runs');await expect(recorded(page)).toContainText('40 loaded positions');
await expect(recorded(page).locator('.history-status')).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(marker(page,oldRun)).toHaveCount(0);
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
});
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
await reviewCapture(page,testInfo,`missing-selected-${width}-${theme}`,missing);
await reviewCapture(page,testInfo,`missing-dialog-${width}-${theme}`,dialog(page));
await command(request,{action:'make-missing-available'});await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeVisible();await expect(missing).toHaveAttribute('aria-current','true');
await reviewCapture(page,testInfo,`available-selected-${width}-${theme}`,missing);await page.keyboard.press('Escape');
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const range=await reset.json();await open(page,range.positive_id);
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
const ids=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun)),oldRun=ids[0];
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const before=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));await reviewCapture(page,testInfo,`range-before-${width}-${theme}`,timeline);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
await page.evaluate(id=>{location.hash=`#/library/${id}`;},range.positive_id);await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');
const after=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(after.left-before.left)).toBeLessThanOrEqual(1);expect(Math.abs(after.top-before.top)).toBeLessThanOrEqual(1);
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(ids);
await reviewCapture(page,testInfo,`range-after-${width}-${theme}`,timeline);
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(recorded(page)).toContainText('149 distinct recorded runs');
await reviewCapture(page,testInfo,`outside-window-${width}-${theme}`,recorded(page));
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
const hashes={};
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
});
test('output capture distinguishes unknown, complete zero, partial and conflicts with exact out-of-range time',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);
for(const [name,expected] of [['equal_looking','Reported outputs not captured'],['empty','0 reported outputs · complete'],['whitespace','1 retained reported output · incomplete'],['conflicting','First captured context · conflicting evidence']]) {
await marker(page,f.runs[name]).click();await expect(dialog(page)).toContainText(expected);
if(name==='conflicting') {await expect(dialog(page)).toContainText('Recorded time outside display range');await expect(dialog(page)).toContainText('9223372036854775807');}
await page.keyboard.press('Escape');
}
});
test('history, run and output failures retry their own reads and route exit ignores late callbacks',async({page})=>{
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
await expect(dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true})).toBeVisible();outputFailure=false;await dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true}).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await page.keyboard.press('Escape');let release;const held=new Promise(resolve=>{release=resolve;});await page.route(/\/history\/runs\/[^/]+\?/,async route=>{await held;await route.continue().catch(()=>{});});
await marker(page,f.runs.empty).click();await page.evaluate(()=>{location.hash='#/overview';});release();await expect(page.locator('#workspace-main h1')).toHaveText('Overview');await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('more than 1000 real runs retain sparse columns, focused identity and pixel anchors on prepend with moveBefore absent',async({page,request})=>{
test.setTimeout(120000);const response=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:1001}});await expect(response).toBeOK();const f=await response.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;Element.prototype.moveBefore=undefined;});await open(page,f.positive_id);
const more=recorded(page).getByRole('button',{name:'Load earlier runs',exact:true,includeHidden:true});
await expect(recorded(page).locator('[data-history-run]')).toHaveCount(40);
const first=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button').first();
await first.focus();await first.evaluate(node=>{window.retainedHistoryButton=node;window.retainedHistoryRun=node.dataset.runId;window.retainedHistoryX=node.getBoundingClientRect().left;window.retainedHistoryY=node.getBoundingClientRect().top;});
await more.evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
const retained=await page.evaluate(()=>({same:window.retainedHistoryButton===document.querySelector(`[data-run-id="${window.retainedHistoryRun}"]`),focus:document.activeElement===window.retainedHistoryButton,delta:Math.abs(window.retainedHistoryX-window.retainedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.retainedHistoryY-window.retainedHistoryButton.getBoundingClientRect().top)}));
expect(retained.same&&retained.focus).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
while(await more.isVisible()) {await more.evaluate(node=>node.click());await expect(more).not.toHaveAttribute('aria-disabled','true');}
await expect(recorded(page)).toContainText('1004 loaded positions');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=node.scrollWidth/2;node.dispatchEvent(new Event('scroll'));});
const bound=await timeline.evaluate(node=>Math.ceil(node.clientWidth/64)+8);
await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound);
expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound);
expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
});
test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{
await page.setViewportSize({width:390,height:844});await open(page,page.fixture.detail_id);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.locator('#workspace-main').evaluate(node=>{node.style.zoom='2';});
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await marker(page,page.fixture.runs.shared).click();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);await page.keyboard.press('Escape');
await page.locator('#workspace-main').evaluate(node=>node.style.removeProperty('zoom'));
await page.emulateMedia({forcedColors:'active'});await expect(marker(page,page.fixture.runs.shared)).toBeVisible();
});
test('frozen Effective pair uses the shared acknowledged sender and Escape closes only its top dialog',async({page,context})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'history-pair',label:'History destination',type:'PromptManagerPair',roles:['positive','negative']};window.historyApplied=[];
window.historyHost=createBridgeHost({origin:location.origin,namespace:'/',label:'History ComfyUI',adapter:{listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
apply:roles=>{window.historyApplied.push(roles);return{targetId:target.id,values:roles};}}});
});
try {
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
});
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(recorded(page)).toBeVisible();
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','true');
await marker(page,page.fixture.runs.shared).evaluate(node=>node.click());await expect(dialog(page)).toHaveCount(0);release();
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','false');expect(checks).toBeGreaterThan(0);await expect(dialog(page)).toHaveCount(0);
await marker(page,page.fixture.runs.shared).click();await expect(dialog(page)).toContainText(page.fixture.runs.shared);await page.keyboard.press('Escape');
});
test('native connected moves and keyboard navigation preserve marker identity through earlier branch reorder',async({page,request})=>{
const result=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:140}});await expect(result).toBeOK();const f=await result.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;window.historyMoves=0;const move=Element.prototype.moveBefore;window.hasHistoryMove=typeof move==='function';if(move)Element.prototype.moveBefore=function(...args){window.historyMoves++;return move.apply(this,args);};});
await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(40);await buttons.first().focus();
await buttons.first().evaluate(node=>{window.keyedHistoryButton=node;window.keyedHistoryX=node.getBoundingClientRect().left;window.keyedHistoryY=node.getBoundingClientRect().top;});
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
const retained=await page.evaluate(()=>({same:window.keyedHistoryButton.isConnected,focused:document.activeElement===window.keyedHistoryButton,delta:Math.abs(window.keyedHistoryX-window.keyedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.keyedHistoryY-window.keyedHistoryButton.getBoundingClientRect().top)}));
expect(retained.same&&retained.focused).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
if(await page.evaluate(()=>window.hasHistoryMove)) expect(await page.evaluate(()=>window.historyMoves)).toBeGreaterThan(0);
await page.keyboard.press('End');await expect(recorded(page).locator('[data-history-run]').last()).toHaveAttribute('aria-posinset','80');
expect(await page.evaluate(()=>document.activeElement.closest('[data-history-run]')?.getAttribute('aria-posinset'))).toBe('80');
});
test('coarse-pointer markers expose 44px targets while retaining the small repeat visual',async({browser,request})=>{
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true});const page=await context.newPage();
try {
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(3);
expect(await buttons.evaluateAll(nodes=>nodes.every(node=>node.getBoundingClientRect().width>=44&&node.getBoundingClientRect().height>=44))).toBe(true);
const repeat=marker(page,f.aab[1]);expect(await repeat.evaluate(node=>({width:getComputedStyle(node,'::before').width,height:getComputedStyle(node,'::before').height}))).toEqual({width:'12px',height:'12px'});
await repeat.tap();await expect(dialog(page)).toContainText(f.aab[1]);await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).tap();await expect(dialog(page)).toHaveCount(0);
} finally {await context.close();}
});
test('coarse recorded toolbar and disclosure expose 44px targets and real touch actions',async({browser,browserName,request},testInfo)=>{
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:browserName!=='firefox'});const page=await context.newPage();
try {
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);
expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);
const controls=recorded(page).locator('.history-toolbar button:visible,.history-scope summary');
const sizes=await controls.evaluateAll(nodes=>nodes.map(node=>({label:node.textContent,width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
await testInfo.attach('coarse-recorded-controls.json',{body:JSON.stringify(sizes,null,2),contentType:'application/json'});
for(const control of sizes) {expect.soft(control.width,control.label).toBeGreaterThanOrEqual(44);expect.soft(control.height,control.label).toBeGreaterThanOrEqual(44);}
await recorded(page).getByText('Recorded history scope',{exact:true}).tap();await expect(recorded(page).locator('.history-scope')).toHaveAttribute('open','');
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).tap();await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(3);
} finally {await context.close();}
});
test('capture recovery reloads only reported outputs while retaining frozen run and saved gallery',async({page})=>{
const f=page.fixture;let failures=2,outputReads=0,runReads=0,galleryReads=0;
page.on('request',request=>{const path=new URL(request.url()).pathname;if(path===`/prompt_manager/history/runs/${f.runs.shared}`)runReads++;if(path===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>{
outputReads++;if(failures-->0)return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
await route.continue();
});
await open(page,f.detail_id);await marker(page,f.runs.shared).click();
const reload=dialog(page).getByRole('button',{name:'Reload reported outputs',exact:true});await expect(reload).toBeVisible();expect(outputReads).toBe(2);
const runCount=runReads,galleryCount=galleryReads;await reload.click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
expect(runReads).toBe(runCount);expect(galleryReads).toBe(galleryCount);expect(outputReads).toBe(3);await expect(dialog(page)).toContainText(f.runs.shared);
});
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
const retry=page.getByRole('button',{name:'Retry saved Library details',exact:true});await expect(retry).toBeVisible();await expect(page.locator('.library-parent-status')).toContainText('could not be revalidated');
parentFailure=false;await retry.click();await expect(page.locator('.library-parent-status')).toContainText('revalidated');await expect(retry).toBeHidden();
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();expect(galleryReads).toBe(0);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
});
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
for(const variant of details.variants) await expect(await request.delete(`/prompt_manager/pairs/${variant.id}`)).toBeOK();
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
});
test('a bounded saved-parent prefix leaves previously loaded variants unverified without claiming deletion',async({page,request})=>{
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('not observed in this bounded revalidation page');await expect(page.locator('.library-parent-status')).not.toContainText(/deleted|removed/i);
await expect(choices.last()).toHaveAttribute('aria-pressed','true');expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled();
});
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
return async()=>{
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
};
}
for(const change of ['mode','anchor']) test(`initial receipt read restarts after a held ${change} cancellation`,async({page,request})=>{
const f=page.fixture;const retained=await retainSavedSelection(page,request,f);let reads=0,release,entered;
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
let aborted=0;page.on('requestfailed',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.shared}/outputs`)aborted++;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;if(reads===1){entered();await held;}await route.continue().catch(()=>{});});
try {
await marker(page,f.runs.shared).click();await started;
if(change==='mode')await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
else await dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /}).click();
release();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(2);
await expect.poll(()=>aborted).toBeGreaterThan(0);
if(change==='mode')await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
else await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /})).toHaveAttribute('aria-pressed','true');
await page.keyboard.press('Escape');await retained();
} finally {release();}
});
test('same-run reopen loads page one after cancellation and a held fresh detail read',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseOutputs,enteredOutputs,releaseDetail,enteredDetail;
const outputsHeld=new Promise(resolve=>{releaseOutputs=resolve;}),outputsStarted=new Promise(resolve=>{enteredOutputs=resolve;});
const detailHeld=new Promise(resolve=>{releaseDetail=resolve;}),detailStarted=new Promise(resolve=>{enteredDetail=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;enteredOutputs();await outputsHeld;await route.continue().catch(()=>{});});
try {
await marker(page,f.runs.shared).click();await outputsStarted;await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{enteredDetail();await detailHeld;await route.continue().catch(()=>{});});
await marker(page,f.runs.shared).click();await detailStarted;await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(dialog(page)).toContainText('Loading recorded run');
releaseDetail();releaseOutputs();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(3);
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');await page.keyboard.press('Escape');await retained();
} finally {releaseDetail();releaseOutputs();}
});
test('late output capture replaces the earlier uncaptured detail summary with real complete-zero evidence',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let release,entered;
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
for(const data of [{}, {run_id:f.runs.equal_looking,extra:true}, {run_id:[]}, {run_id:'not-a-run'}])expect((await request.post('/__test__/history/capture-zero-outputs',{data})).status()).toBe(400);
await page.route(new RegExp(`/history/runs/${f.runs.equal_looking}/outputs\\?`),async route=>{entered();await held;await route.continue().catch(()=>{});});
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}`);
try {
await marker(page,f.runs.equal_looking).click();await started;const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('not_captured');
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.equal_looking}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
const pageRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}/outputs`);release();const outputs=await(await pageRead).json();
expect(outputs.capture).toMatchObject({state:'complete',reason:'none_reported'});expect(outputs.retained_occurrence_count).toBe(0);expect(outputs.list_status).toBe('complete');
await expect(dialog(page).locator('.recorded-output-status')).toHaveText('0 reported outputs · complete · All retained receipts loaded.');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · none_reported');
await expect(dialog(page).getByText('Context capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · captured');
await page.keyboard.press('Escape');await retained();
} finally {release();}
});
test('capture-only recovery uses the changed real output capture without re-reading run or saved gallery',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let outputsReads=0,runReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`)runReads++;});
await page.route(new RegExp(`/history/runs/${f.runs.whitespace}/outputs\\?`),async route=>{
outputsReads++;
if(outputsReads===1) {
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.whitespace}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
}
await route.continue();
});
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`);
const outputsRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}/outputs`&&response.status()===200);
await marker(page,f.runs.whitespace).click();const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('partial');
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
});
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseNext,enteredNext;
const held=new Promise(resolve=>{releaseNext=resolve;}),started=new Promise(resolve=>{enteredNext=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.paged}/outputs\\?`),async route=>{
reads++;if(new URL(route.request().url()).searchParams.has('cursor')){enteredNext();await held;}await route.continue().catch(()=>{});
});
try {
await marker(page,f.runs.paged).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);expect(reads).toBe(1);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const more=dialog(page).getByRole('button',{name:'Load more reported outputs',exact:true});await expect(more).toBeEnabled();expect(reads).toBe(1);await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);
await more.click();await started;expect(reads).toBe(2);releaseNext();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(41);await expect(more).toBeHidden();
await page.keyboard.press('Escape');await retained();
} finally {releaseNext();}
});
+14
View File
@@ -427,3 +427,17 @@ test('unchanged row observations settle without resize feedback',async({page,req
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();}
});
test('saved choices retain curved staggered lanes and the full negative immediately follows its positive',async({page,request})=>{
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
});
+109
View File
@@ -1,4 +1,7 @@
import { writeFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
@@ -43,6 +46,112 @@ test.beforeEach(async ({ page, request, browserName }) => {
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
test('canonical history assets are the exact immutable approved source', async ({ request }) => {
const assets = [
['/__test__/canonical/workspace', 'docs/concepts/workspace/index.html', 'd84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03'],
['/workspace/app.js', 'docs/concepts/workspace/app.js', 'e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e'],
['/workspace/style.css', 'docs/concepts/workspace/style.css', '2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8'],
];
for (const [url, source, sha256] of assets) {
const response = await request.get(url);await expect(response).toBeOK();
const bytes = await response.body();
expect(bytes).toEqual(await readFile(new URL(`../../${source}`, import.meta.url)));
expect(createHash('sha256').update(bytes).digest('hex')).toBe(sha256);
expect(response.headers()['cache-control']).toBe('no-store');
expect(response.headers()['x-content-type-options']).toBe('nosniff');
}
for (const url of ['/workspace/missing.js', '/__test__/canonical/server.py', '/toolkit/missing.js', '/vendor/missing.js']) {
expect((await request.get(url)).status()).toBe(404);
}
});
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
test(`canonical chronological history ${width} ${theme}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await page.goto('/__test__/canonical/workspace#prompt/alpine');
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await expect(page.locator('.timeline')).toBeVisible();
const referenceDot = page.getByRole('button', { name: 'Select negative Quality baseline', exact: true });
await referenceDot.focus();await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: 'Select negative Quality baseline', exact: true })).toHaveAttribute('aria-pressed', 'true');
const reference = await historyGeometry(page, false);
for (const [name, selector] of [['saved', '.surface'], ['branches', '.timeline-panel'], ['gallery', '.history-gallery']]) {
await captureCanonicalState(page, testInfo, `concept-${name}-${width}-${theme}`, selector);
}
await page.getByRole('button', { name: 'Show repeat run for Quality baseline', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Repeat run', exact: true })).toBeVisible();
expect(await page.locator('#modal').evaluate(node => node.matches(':modal'))).toBe(true);
reference.runDialog = await nativeDialogState(page.locator('#modal'));
await captureCanonicalState(page, testInfo, `concept-run-${width}-${theme}`, '#modal');
await page.keyboard.press('Escape');await expect(page.locator('#modal')).not.toBeVisible();
await page.locator('.history-gallery .image-open').first().click();
await expect(page.locator('#viewer-dialog')).toBeVisible();
await expect(page.locator('#viewer-dialog')).toHaveAccessibleName('Alpine observatory · alpine_00001.png');
await captureCanonicalState(page, testInfo, `concept-viewer-${width}-${theme}`, '#viewer-dialog');
await page.keyboard.press('Escape');
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
const actual = await historyGeometry(page, true);
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-shared-gallery .image-open').first().click();
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
await run.focus();await page.keyboard.press('Enter');
const runDialog = page.getByRole('dialog', { name: 'Recorded run', exact: true });
await expect(runDialog.locator('[data-receipt]')).toHaveCount(2);
expect(await runDialog.evaluate(node => node.matches(':modal') && node.contains(document.activeElement))).toBe(true);
actual.runDialog = await nativeDialogState(runDialog);
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
await compareCanonicalHistory(reference, actual, testInfo);
const history = await request.get(`/prompt_manager/library/${fixture.positive_id}/history`);await expect(history).toBeOK();
const window = await history.json();expect(window.items.map(item => item.run_id)).toEqual(fixture.aab);
expect(window.context_summaries).toHaveLength(2);
const contextIds = [...new Set(window.items.flatMap(item => item.matching_anchors.map(anchor => anchor.context_id)))];
expect(contextIds).toHaveLength(2);
await page.goto(`${workspace}#/library/${fixture.positive_id}`);
await expect(page.locator('.history-run-marker')).toHaveCount(3);
await page.locator('.history-timeline').evaluate(node => { node.scrollLeft = 0;node.scrollTop = 0; });
const anchor = page.locator('.history-context-anchor').first();await anchor.focus();await expect(anchor).toBeFocused();
const multiple = await historyGeometry(page, true);
expect(multiple.recordedBranch.map(branch => branch.contextId)).toEqual(contextIds);
expect(multiple.recordedMarker.map(marker => marker.runId)).toEqual(fixture.aab);
await captureCanonicalState(page, testInfo, `production-chronology-multiple-${width}-${theme}`, '.recorded-history');
const geometryPath = testInfo.outputPath('canonical-recorded-geometry.json');
await writeFile(geometryPath, JSON.stringify({ reference, actual: multiple, expected: { contextIds, runIds: fixture.aab, branchCount: 2, anchorCount: 2, repeatCount: 1 }, differences: 'Two real frozen contexts / three runs versus three illustrative saved variants; chronological columns retain 64px run spacing, with the same role colors, curves and 57px row rhythm.' }, null, 2));
await testInfo.attach('canonical-recorded-geometry', { path: geometryPath, contentType: 'application/json' });
assertRecordedHistory(reference, multiple, { branchCount: 2, anchorCount: 2, repeatCount: 1 });
});
}
test('visual fixture exposes real stable media and library records', async ({ request }) => {
expect((await request.post('/__test__/reset', { data: { workspace_visual: 'true' } })).status()).toBe(400);
for (const option of ['extra_images', 'include_video', 'prompt_inspector', 'native_library', 'extra_prompts', 'extra_variants']) {
+538
View File
@@ -0,0 +1,538 @@
"""Real disposable ledger commands and physical files for history workflows."""
from __future__ import annotations
from dataclasses import replace
import json
import os
from pathlib import Path
import threading
from types import SimpleNamespace
from typing import TYPE_CHECKING
from uuid import UUID, uuid5
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_visual_fixture import _write_art, seed_workspace_visual
from utils.execution_contexts import (
ContextBundle,
PromptSnapshot,
RecipeContext,
TextEvidence,
)
from utils.execution_history_events import make_finish, make_start
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
if TYPE_CHECKING:
from py.api import PromptManagerAPI
HISTORY_POSITIVE = (
"Chronology A,A,B\nExact positive 猫 😀 " + "fine light " * 35
).rstrip()
DETAIL_POSITIVE = "Frozen evidence\n" + "unbroken-" * 90
LONG_NEGATIVE = "same visible negative\n" + "exact detail 猫 😀 " * 70
SESSION = "69f04366-6384-4182-b84b-117597479127"
class _HistoryCommands:
"""One disposable fixture lifetime and one bounded publication barrier."""
def __init__(self, api: PromptManagerAPI, root: Path) -> None:
self.api = api
self.root = root
self.manifest = None
self.entered = threading.Event()
self.release = threading.Event()
self.restore_seam = None
import folder_paths
self.original_output = folder_paths.get_output_directory
def reset(self, manifest: dict | None = None) -> None:
self.disarm()
import folder_paths
folder_paths.get_output_directory = self.original_output
self.manifest = manifest
def disarm(self) -> None:
self.release.set()
if self.restore_seam:
module, name, original = self.restore_seam
setattr(module, name, original)
self.restore_seam = None
def arm(self, boundary: str) -> None:
from py.api import history
from py.api import inspection_service
self.disarm()
self.entered = threading.Event()
self.release = threading.Event()
name = {
"window": "_serialize_history",
"detail": "_serialize_history",
"candidate": "resolve_current_candidate",
"inspection": "read_inspection_file",
"media": "_prepare_current_media",
}[boundary]
module = inspection_service if boundary == "inspection" else history
original = getattr(module, name)
entered, release = self.entered, self.release
def held(*args: object, **kwargs: object) -> object:
value = original(*args, **kwargs)
setattr(module, name, original)
entered.set()
try:
if not release.wait(10):
raise TimeoutError("Owned history publication barrier timed out")
return value
except BaseException:
if boundary == "media":
value.close()
raise
self.restore_seam = module, name, original
setattr(module, name, held)
def register_workspace_history_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> _HistoryCommands:
"""Register fixed commands; all files and namespaces belong to this server."""
state = _HistoryCommands(api, root)
api._workspace_history_commands = state
@routes.post("/__test__/history/commands")
async def command(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
actions = {
"release",
"replace-file",
"reconfigure-root",
"make-missing-available",
"append-history-runs",
}
if (
not isinstance(data, dict)
or type(data.get("action")) is not str
or ("boundary" in data and type(data["boundary"]) is not str)
or not (
(set(data) == {"action"} and data["action"] in actions)
or (
set(data) == {"action", "boundary"}
and data["action"] == "arm"
and data["boundary"]
in {"window", "detail", "candidate", "inspection", "media"}
)
)
):
raise web.HTTPBadRequest()
if state.manifest is None:
raise web.HTTPConflict()
action = data["action"]
if action == "arm":
state.arm(data["boundary"])
elif action == "release":
state.disarm()
elif action == "replace-file":
replacement = root / "output" / "history" / "replacement.png"
with Image.new("RGB", (160, 100), (225, 30, 40)) as image:
image.save(replacement)
replacement.replace(root / "output" / "history" / "current.png")
elif action == "make-missing-available":
target = root / "output" / "history" / "missing.png"
with Image.new("RGB", (160, 100), (30, 150, 70)) as image:
image.save(target)
elif action == "append-history-runs":
session = str(uuid5(UUID(SESSION), "fixed-late-history"))
context = RecipeContext(
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
)
for number in range(61):
job = str(uuid5(UUID(session), str(number)))
start = make_start(
session,
((0, job, {}, {}), number),
api.db.get_execution_epoch(),
1_790_000_000_000 + number,
)
api.db.write_execution_start(start)
api.db.write_execution_finish(
make_finish(
session,
number,
start.storage_epoch,
SimpleNamespace(
messages=[("execution_success", {"prompt_id": job})]
),
1_790_000_000_010 + number,
)
)
api.db.write_execution_contexts(
start.run_id,
start.storage_epoch,
ContextBundle((context,), "complete", "captured"),
)
elif action == "reconfigure-root":
import folder_paths
moved = root / "owned-reconfigured-output"
moved.mkdir(exist_ok=True)
folder_paths.get_output_directory = lambda: str(moved)
return web.json_response({"success": True})
@routes.get("/__test__/history/checkpoint")
async def checkpoint(request: web.Request) -> web.Response:
if state.manifest is None:
raise web.HTTPConflict()
return web.json_response({"entered": state.entered.is_set()})
@routes.get("/__test__/history/snapshot")
async def snapshot(request: web.Request) -> web.Response:
if state.manifest is None or request.query:
raise web.HTTPConflict()
path = root / "owned-history-snapshot.db"
created = await api._run_in_executor(api.db.model.backup_database, str(path))
if not created:
raise web.HTTPInternalServerError()
return web.Response(
body=path.read_bytes(), content_type="application/octet-stream"
)
@routes.get("/__test__/history/reused-snapshot")
async def reused_snapshot(request: web.Request) -> web.Response:
if state.manifest is None or request.query:
raise web.HTTPConflict()
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
path = root / "owned-history-reused.db"
def create() -> bytes:
for suffix in ("", "-wal", "-shm"):
Path(str(path) + suffix).unlink(missing_ok=True)
database = PromptDatabase(str(path))
try:
target = state.manifest["detail_id"]
with database.model.get_connection() as connection:
text = "Restored owner with reused numeric ID"
connection.execute(
"INSERT INTO prompts(id,text,hash) VALUES(?,?,?)",
(target, text, generate_prompt_hash(text)),
)
database.save_prompt_pair(text, "Restored saved negative")
finally:
database.close_all()
return path.read_bytes()
return web.Response(
body=await api._run_in_executor(create),
content_type="application/octet-stream",
)
async def cleanup(application: web.Application) -> None:
state.reset()
api_app = __import__("server").PromptServer.instance.app
api_app.on_cleanup.append(cleanup)
return state
def _known(value: str, source: str = "runtime_return") -> TextEvidence:
provenance = "inferred" if source.startswith("graph_") else "recorded"
return TextEvidence(value, provenance, source, None)
def _snapshot(raw: str, suffix: str = "") -> PromptSnapshot:
return PromptSnapshot(_known(raw), _known(raw + suffix), _known(""), _known(suffix))
def capture_workspace_history_zero_outputs(api: PromptManagerAPI, run_id: str) -> bool:
"""Apply one late host capture through the normal immutable storage command."""
run = api.db.get_execution_run(run_id)
finish = make_finish(
run["session_id"],
run["queue_item_id"],
run["storage_epoch"],
SimpleNamespace(
messages=[
(
"execution_success",
{"prompt_id": run["job_id"], "timestamp": run["terminal_event_at"]},
)
]
),
run["observed_callback_at"],
)
return api.db.write_execution_outputs_for_finish(
finish, OutputBundle((), "complete", "none_reported")
)
def seed_workspace_history(
api: PromptManagerAPI, root: Path, *, history_runs: int = 0
) -> dict:
"""Write actual captures; reset removes only this server's disposable rows."""
api._workspace_history_commands.reset()
seed_workspace_visual(api, root)
with api.db.model.get_connection() as conn:
for table in (
"execution_run_outputs",
"execution_output_capture",
"execution_run_contexts",
"execution_context_capture",
"execution_runs",
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
):
conn.execute(f"DELETE FROM {table}")
epoch = api.db.get_execution_epoch()
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
folder = root / "output" / "history"
folder.mkdir(exist_ok=True)
for index, name in enumerate(("current.png", "other.png")):
with Image.new("RGB", (160, 100), (50, 80 + index * 50, 110)) as image:
image.save(folder / name)
def reference(
node: str, ordinal: int, name: str, kind: str = "output"
) -> OutputReference:
return OutputReference(
node,
ordinal,
kind,
"history",
name,
"posix",
roots.output_key if kind == "output" else roots.temp_key,
)
item = 0
def run(
contexts: tuple[RecipeContext, ...],
outputs: OutputBundle | None = None,
*,
observed: int | None = None,
conflicting: bool = False,
) -> str:
nonlocal item
job = str(uuid5(UUID(SESSION), f"job-{item}"))
start = make_start(
SESSION,
((item, job, {}, {}), item),
epoch,
1_780_000_000_000 + item if observed is None else observed,
)
api.db.write_execution_start(start)
messages = [("execution_success", {"prompt_id": job, "timestamp": item + 1})]
finish = make_finish(
SESSION, item, epoch, SimpleNamespace(messages=messages), item + 10
)
api.db.write_execution_finish(finish)
partial = any(
getattr(side, field).value is None
for context in contexts
for side in (context.positive, context.negative)
if side is not None
for field in ("raw", "effective", "prepend", "append")
)
api.db.write_execution_contexts(
start.run_id,
epoch,
ContextBundle(
contexts,
"partial" if partial else "complete",
"partial" if partial else "captured",
),
)
if outputs is not None:
api.db.write_execution_outputs_for_finish(finish, outputs)
if conflicting:
api.db.write_execution_contexts(
start.run_id,
epoch,
ContextBundle(
(replace(contexts[0], negative=_snapshot("later")),),
"complete",
"captured",
),
)
api.db.write_execution_outputs_for_finish(
finish, OutputBundle((), "complete", "none_reported")
)
item += 1
return start.run_id
pair_a = api.db.save_prompt_pair(
HISTORY_POSITIVE, "A negative", title="Saved A", category="History"
)
api.db.save_prompt_pair(HISTORY_POSITIVE, "B negative", title="Saved B")
positive_id = api.db.get_prompt_pair(pair_a)["positive_prompt_id"]
image = api.db.link_image_to_prompt(positive_id, str(folder / "current.png"))
api.db.link_image_to_pair(pair_a, image, {"source": "history browser fixture"})
a = RecipeContext(
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
)
b = replace(a, negative=_snapshot("B negative"))
aab = [run((context,)) for context in (a, a, b)]
for number in range(history_runs):
context = (
a
if number % 3 == 0
else (
b
if number % 3 == 1
else replace(b, negative=_snapshot(f"Distinct context {number:04}"))
)
)
run((context,))
saved_pair = api.db.save_prompt_pair(
DETAIL_POSITIVE, LONG_NEGATIVE, title="Saved exact negative"
)
detail_id = api.db.get_prompt_pair(saved_pair)["positive_prompt_id"]
api.db.save_prompt_pair(DETAIL_POSITIVE, "", title="Saved empty")
current = api.db.link_image_to_prompt(detail_id, str(folder / "current.png"))
api.db.link_image_to_pair(saved_pair, current, {"source": "fixture"})
x = RecipeContext(
"10",
_snapshot(DETAIL_POSITIVE, " + composition A"),
_snapshot(LONG_NEGATIVE, " A"),
"inferred",
)
y = replace(x, negative=_snapshot(LONG_NEGATIVE, " B"))
receipts = OutputBundle(
(
reference("10", 0, "current.png"),
reference("10", 1, "current.png"),
reference("2", 0, "other.png"),
reference("2", 1, "missing.png"),
reference("z", 0, "transient.png", "temp"),
),
"complete",
"reported",
)
detail_runs = {
"shared": run(
(x, replace(x, anchor_id="2"), replace(y, anchor_id="z")), receipts
),
"equal_looking": run((y,)),
"empty": run(
(replace(x, negative=_snapshot("")),),
OutputBundle((), "complete", "none_reported"),
),
"unknown": run(
(
replace(
x,
negative=PromptSnapshot(
TextEvidence(None, "unknown", "unknown", "missing"),
_known(" \n\t ", "host_cache_output"),
_known("", "graph_literal"),
_known("", "graph_semantics"),
),
),
)
),
"whitespace": run(
(
replace(
x,
negative=PromptSnapshot(
_known(" \n\t ", "graph_literal"),
_known(" \n\t ", "host_cache_output"),
_known("", "graph_literal"),
_known("", "graph_semantics"),
),
),
),
OutputBundle(
(reference("1", 0, "current.png"),), "partial", "limit_reached"
),
),
"conflicting": run(
(x,), receipts, conflicting=True, observed=9_223_372_036_854_775_807
),
"paged": run(
(x,),
OutputBundle(
tuple(
reference("paged", ordinal, "current.png") for ordinal in range(41)
),
"complete",
"reported",
),
),
}
canonical_positive = (
"Layered sandstone arches, warm afternoon light, precise silhouettes."
)
canonical_negative = "blurred edges, watermark"
canonical_pair = api.db.save_prompt_pair(
canonical_positive, canonical_negative, title="Quality baseline"
)
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
path = folder / name
_write_art(path, number, canonical_positive, canonical_negative)
with Image.open(path) as source:
graph = json.loads(source.info["prompt"])
graph["3"]["inputs"].update(
seed=42001 + number,
steps=28,
cfg=6.5,
sampler_name="euler",
scheduler="normal",
)
metadata = PngInfo()
metadata.add_text("prompt", json.dumps(graph))
source.save(path, pnginfo=metadata)
stamp = 1_760_000_000 - number * 60
os.utime(path, (stamp, stamp))
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
canonical_run = run(
(
RecipeContext(
"10",
_snapshot(canonical_positive, " + frozen composition"),
_snapshot(canonical_negative),
"inferred",
),
),
OutputBundle(
(
reference("10", 0, "canonical.png"),
reference("10", 1, "canonical-alt.png"),
),
"complete",
"reported",
),
)
merge_duplicate_id = api.db.save_prompt(DETAIL_POSITIVE)
api.invalidate_gallery_cache()
manifest = {
"positive_id": positive_id,
"detail_id": detail_id,
"aab": aab,
"runs": detail_runs,
"saved_pair": saved_pair,
"detail_image_id": current,
"canonical_id": canonical_id,
"canonical_pair": canonical_pair,
"canonical_run": canonical_run,
"canonical_positive": canonical_positive,
"canonical_negative": canonical_negative,
"merge_duplicate_id": merge_duplicate_id,
}
api._workspace_history_commands.reset(manifest)
return manifest
+77
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, readExecutionRuns } from './execution-fixture.mjs';
import { installHostHooks } from './host-fixture.mjs';
const sensitive = ['PM_SECRET_PROMPT_209', 'PM_SECRET_TOKEN_209', 'PM_SECRET_TRACE_209'];
const cached = value => ({ '1': { class_type: 'PMHistoryCached', inputs: { value } } });
@@ -194,9 +195,85 @@ async function capture(execution, id, outcome = 'succeeded') {
result = await readExecutionRuns(execution.databasePath, id);
return result.captures[run.run_id].state;
}, { timeout: 10000, message: 'Background writer persisted context finalization' }).not.toBe('not_captured');
const response = await execution.request.get(`/prompt_manager/history/runs/${run.run_id}`);
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
const detail = await response.json();
const fields = contexts => contexts.map(({ anchor_id, context_id, positive, negative, evidence }) => ({ anchor_id, context_id, positive, negative, evidence }));
expect(fields(detail.contexts)).toEqual(fields(result.captures[run.run_id].contexts));
expect(detail.run).toMatchObject({ run_id: run.run_id, outcome, queue_item_id: String(run.queue_item_id) });
return result.captures[run.run_id];
}
test.describe('public chronological history with the actual acknowledged host bridge', () => {
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
installHostHooks(test, ['PromptManagerPair']);
test('three actual attempts expose two contexts and send exact frozen roles through both renderer switches and reload', async ({ execution, page, context }, testInfo) => {
test.setTimeout(120000);
const positive = `public chronology ${testInfo.project.name} ${randomUUID()}\n猫`, attempts = [];
for (const negative of ['negative A', 'negative A', 'negative B']) {
const graph = recipeGraph({ text: positive, negative, prepend: ' pre ', append: ' post ' });
graph['60'] = { class_type: 'PMHistoryImages', inputs: { value: 128, batch: 2, latent: ['40', 0] } };
graph['70'] = { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: 'chronology/public' } };
graph['71'] = { class_type: 'PreviewImage', inputs: { images: ['60', 0] } };
const id = await submit(execution.request, graph);
await history(execution.request, id);const frozen = await capture(execution, id);
const persisted = await readExecutionRuns(execution.databasePath, id);attempts.push({ row: persisted.rows[0], context: frozen.contexts[0] });
}
const positiveId = attempts[0].context.positive_prompt_id;
const response = await execution.request.get(`/prompt_manager/library/${positiveId}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(3);expect(window.items.map(item => item.run_id)).toEqual(attempts.map(item => item.row.run_id));
expect(window.context_summaries).toHaveLength(2);expect(new Set(attempts.map(item => item.context.positive.raw))).toEqual(new Set([positive]));
expect(attempts.map(item => item.context.evidence.positive.effective.source)).toEqual(['runtime_return', 'host_cache_output', 'runtime_return']);
const workspace = await context.newPage();const errors = [];workspace.on('pageerror', error => errors.push(error.message));
try {
const destination = await page.evaluate(async () => {const { app } = await import('/scripts/app.js');const node = LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {const { app } = await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);await app.loadGraphData(app.graph.serialize());}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await workspace.goto(`/prompt_manager/workspace#/library/${positiveId}`);
const recorded = workspace.getByRole('region', { name: 'Recorded executions', exact: true });await expect(recorded.locator('[data-context-branch]')).toHaveCount(2);
await recorded.locator(`button[data-run-id="${attempts[0].row.run_id}"]`).click();
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
await expect(run.locator('[data-receipt]')).toHaveCount(4);
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
const frozen = attempts[0].context;
for (const mode of ['Raw', 'Effective']) {
await run.getByRole('button', { name: mode, exact: true }).click();
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
const applied = await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);return { positive: node.widgets.find(w => w.name === 'text').value, negative: node.widgets.find(w => w.name === 'negative_text').value, effects: window.chronologyEffects };}, destination);
expect(applied).toEqual({ positive: frozen.positive[mode.toLowerCase()], negative: frozen.negative[mode.toLowerCase()], effects: { queue: 0, load: 0 } });
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
} finally {await page.evaluate(async () => {const { app } = await import('/scripts/app.js');Object.assign(app, window.chronologyOriginals);});}
}
await workspace.keyboard.press('Escape');await expect(recorded).toBeVisible();
}
const versions = await (await execution.request.get('/system_stats')).json();await testInfo.attach('chronology-runtime.json', { body: JSON.stringify(versions.system, null, 2), contentType: 'application/json' });
expect(errors).toEqual([]);
} finally {await workspace.close();}
});
});
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
const captures = [];
const positiveSnapshotIds = [];
+18
View File
@@ -33,6 +33,24 @@ async function persisted(execution, id, outcomes) {
}, { timeout: 15000, message: 'Exact attempts and final output evidence reach SQLite' }).toEqual({
outcomes, states: outcomes.map(() => 'complete'),
});
for (const row of result.rows) {
const detailResponse = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}`);
expect(detailResponse.ok()).toBe(true);
const detail = await detailResponse.json();
expect(detail.run).toMatchObject({ run_id: row.run_id, outcome: row.outcome, queue_item_id: String(row.queue_item_id) });
const response = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}/outputs?epoch=${detail.history_epoch}`);
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
const publicOutputs = await response.json(), captured = result.outputs[row.run_id];
expect(publicOutputs.capture).toMatchObject({ state: captured.state, reason: captured.reason, source: captured.source });
expect(publicOutputs.retained_occurrence_count).toBe(captured.references.length);
expect(publicOutputs.items.map(item => ({ ...item.occurrence, ...item.reference })).map(({ run_id, ...reference }) => reference)).toEqual(
captured.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key }) =>
({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key })));
for (const item of publicOutputs.items) {
expect(item).toMatchObject({ file_identity: 'unverified', newly_generated: 'unknown', context_membership: 'unknown' });
expect(item.current_candidate.evidence_class).toBe('locator_candidate');
}
}
return result;
}
+31
View File
@@ -222,3 +222,34 @@ test("latestOnly: errors from the current call propagate, errors from superseded
rejectFirst(new Error("old failure"));
assert.equal(await first, STALE);
});
test("ApiError exposes only validated history recovery actions on all error paths", async () => {
const permitted = [null, { action: "refresh-history" }, { action: "reload-outputs" },
{ action: "retry-read" }, { action: "smaller-limit", limit: 1 },
{ action: "smaller-limit", limit: 100 }];
const denied = [undefined, false, "refresh-history", [], {}, { action: "unknown" },
{ action: "refresh-history", private: "secret" }, { action: "retry-read", limit: 1 },
{ action: "smaller-limit" }, { action: "smaller-limit", limit: 0 },
{ action: "smaller-limit", limit: 101 }, { action: "smaller-limit", limit: true },
{ action: "smaller-limit", limit: "1" }, { action: "smaller-limit", limit: 1.5 },
{ action: "smaller-limit", limit: NaN }, { action: "smaller-limit", limit: Infinity },
{ action: "smaller-limit", limit: 10, url: "/private" },
Object.assign(Object.create({ action: "retry-read" }), { private: "secret" })];
assert.equal(new ApiError(409, "old caller").recovery, null);
for (const recovery of permitted.concat(denied)) {
for (const status of [409, 200]) {
const api = createApiClient(async () => jsonResponse(status, {
success: false, error: "bounded", code: "history-changed", recovery,
}));
await assert.rejects(api.get("/x"), error => {
assert.ok(error instanceof ApiError);
assert.equal(error.code, "history-changed");
assert.deepEqual(error.recovery, permitted.includes(recovery) ? recovery : null);
if (recovery && permitted.includes(recovery)) {
assert.notEqual(error.recovery, recovery, "server objects must be copied");
}
return true;
});
}
}
});
+20
View File
@@ -50,3 +50,23 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
}finally{scope.dispose();f.restore();}
});
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
assert.deepEqual(sent[1],{negative:' \n\t '});
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
@@ -48,3 +48,9 @@ test('metadata export preserves representation formats and omits private carrier
assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET'));
assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ');
});
test('reported location metadata export carries exact occurrence without assigning historical ownership',async()=>{
const {inspectionDownloads}=await load(),source=record();
source.identity={kind:'reported-location',key:'b'.repeat(64),path:'folder/a.png',occurrence:{run_id:'22222222-2222-2222-2222-222222222222',output_node_id:'𐀀',image_ordinal:255},history_epoch:'11111111-1111-1111-1111-111111111111',current_scope:'c'.repeat(64),reference:{folder_type:'temp',subfolder:'folder',filename:'a.png',path_style:'posix',root_key:null}};
const value=JSON.parse(inspectionDownloads(source).metadata.bodyText);assert.deepEqual(value.identity,source.identity);assert.equal(value.identity.root_index,undefined);assert.equal(value.context_id,undefined);assert.equal(value.file_identity,undefined);assert.deepEqual(value.prompts,source.prompts);
});
+23
View File
@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/history-state.mjs');
test('chronological columns keep the visible runs, three-column overscan and distant focus in ascending order', async () => {
const {visibleHistoryColumns} = await source;
assert.equal(typeof visibleHistoryColumns, 'function', 'history must expose a bounded column window');
assert.deepEqual(visibleHistoryColumns(1001, 640, 128, 999), [7,8,9,10,11,12,13,14,999]);
assert.deepEqual(visibleHistoryColumns(1001, 0, 64), [0,1,2,3]);
assert.deepEqual(visibleHistoryColumns(1001, 64000, 390, 0), [0,997,998,999,1000]);
assert.deepEqual(visibleHistoryColumns(3, 100, 1), [0,1,2]);
assert.deepEqual(visibleHistoryColumns(0, 0, 500), []);
});
test('column geometry handles fractional edges, invalid scroll and focus without scanning all loaded runs', async () => {
const {visibleHistoryColumns} = await source;
assert.equal(typeof visibleHistoryColumns, 'function');
assert.deepEqual(visibleHistoryColumns(50000, 640.5, 64, 0), [0,7,8,9,10,11,12,13,14]);
assert.deepEqual(visibleHistoryColumns(121, NaN, Infinity), [0,1,2,3]);
assert.equal(visibleHistoryColumns(120, 900, 1).length, 120);
assert.ok(visibleHistoryColumns(10000000, 40000, 390).length <= 14);
for(const count of [-1, 1.1, NaN, '4']) assert.throws(()=>visibleHistoryColumns(count,0,100), TypeError);
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {createInspectionController,originalMediaDownload} from '../../web/js/workspace/inspection-state.mjs';
import {createViewerSession} from '../../web/js/workspace/viewer-state.mjs';
const require=createRequire(import.meta.url),{extractGenerationFacts}=require('../../web/js/comfy-metadata.js');
const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND') throw e;return {};});
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
test('occurrence URLs reject endpoint query origin credentials fragments and identity ambiguities',()=>{const a=adapter(),target=media();for(const patch of [{url:target.url.replace('/media?','/thumbnail?')},{url:target.url+'&node=1'},{url:target.url+'&ordinal=0'},{url:target.url+'&epoch='+epoch},{url:target.url+'&current_scope='+scope},{url:target.url+'&root=0'},{url:target.url+'#x'},{url:'https://attacker.test'+target.url},{url:'http://user:pass@localhost'+target.url},{url:target.url.replace('ordinal=0','ordinal=00')},{url:target.url.replace('node=1','node=2')},{inspection_url:target.url},{thumbnail_url:target.url},{history_epoch:'bad'},{current_scope:'A'.repeat(64)},{location_key:''},{occurrence:{...target.occurrence,image_ordinal:256}},{reference:{...target.reference,subfolder:'../bad'}}]) assert.throws(()=>a.validate({...target,...patch}),TypeError);const cached={...target,url:target.url+'&_pm_viewer=1'};assert.equal(a.originalDownload(cached).url,cached.url);});
test('proxy URLs retain exact configured prefix and reject unprefixed alternatives',()=>{const a=adapter(),previous=globalThis.window;globalThis.window={location:{href:'https://host.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:value=>value.startsWith('/prompt_manager/')?'/proxy'+value:value}};try {const target=media();assert.equal(a.originalDownload(target).url,'/proxy'+target.url);assert.throws(()=>a.originalDownload({...target,url:'https://host.test'+target.url}),TypeError);assert.throws(()=>a.originalDownload({...target,url:'https://host.test.attacker.test/proxy'+target.url}),TypeError);}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}});
test('four repeated receipt coordinates form one loaded Viewer location retaining selected representative',()=>{assert.equal(typeof m.distinctCurrentMedia,'function');const values=[media('1',0),media('1',1),media('2',0),media('2',1)],receipts=values.map(receipt);assert.equal(new Set(receipts.map(r=>m.occurrenceKey(r.occurrence))).size,4);for(const chosen of [values[0],values[3]]) {const items=m.distinctCurrentMedia(receipts,chosen.occurrence);assert.equal(items.length,1);assert.deepEqual(items[0].occurrence,chosen.occurrence);}assert.deepEqual(m.distinctCurrentMedia(receipts,null)[0].occurrence,values[0].occurrence);const unavailable={...receipts[0],current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};assert.deepEqual(m.distinctCurrentMedia([unavailable],null),[]);});
test('shared occurrence inspection keeps current facts and exports exact unverified evidence',async()=>{const a=adapter(),target=media('2',1),data=envelope(target);const record=a.toRecord(data,target,extractGenerationFacts);assert.deepEqual(record.identity,{kind:'reported-location',key:location,path:'folder/a #%.png',occurrence:target.occurrence,history_epoch:epoch,current_scope:scope,reference:target.reference});assert.equal(record.prompts.positive.text,'current metadata only');assert.equal(record.generation.seed.status,'not-recorded');assert.equal(record.identity.root_index,undefined);assert.deepEqual(record.download,{url:target.url,filename:target.filename});const {inspectionDownloads}=await import('../../web/js/workspace/file-inspector.mjs');const exported=JSON.parse(inspectionDownloads(record).metadata.bodyText);assert.deepEqual(exported.identity.occurrence,{run_id:run,output_node_id:'2',image_ordinal:1});assert.equal(exported.identity.history_epoch,epoch);assert.equal(exported.prompts.positive.text,'current metadata only');});
test('identity scope epoch or occurrence echo mismatches require refresh but malformed metadata is retryable',()=>{const a=adapter();for(const mutate of [p=>p.history_epoch='33333333-3333-3333-3333-333333333333',p=>p.current_output.current_scope='d'.repeat(64),p=>p.current_output.occurrence={...p.current_output.occurrence,image_ordinal:1},p=>p.current_output.reference={...p.current_output.reference,filename:'other.png'},p=>p.current_output.location_key='d'.repeat(64)]){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e.refreshRequired===true && typeof e.code==='string');}for(const mutate of [p=>p.success=false,p=>p.inspection_version=2,p=>p.metadata.parameters='fake',p=>p.current_output.file_identity='verified',p=>p.current_output.observation='historical-file']){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e instanceof TypeError && !e.refreshRequired);}});
test('shared inspection controller distinguishes recovery codes and obsolete receipt errors',async()=>{const a=adapter();for(const [status,code,refresh] of [[409,'history-changed',true],[409,'candidate-stale',true],[409,'capture-changed',true],[400,'invalid-request',false],[400,'invalid-cursor',false]]){const calls=[];const c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const p=c.select(media());assert.equal(calls[0].url,media().inspection_url);calls[0].reject({status,code,recovery:{action:'reload-outputs'}});await p;assert.equal(c.state.refreshRequired,refresh);assert.equal(c.state.code,code);const retry=c.retry();assert.equal(calls.length,refresh?1:2);if(!refresh){calls[1].resolve(envelope());await retry;}c.dispose();}const calls=[],c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const first=c.select(media('1',0)),second=c.select(media('1',1));assert.equal(calls.length,2,'same location needs exact occurrence selection');assert.equal(calls[0].signal.aborted,true);calls[1].resolve(envelope(media('1',1)));await second;calls[0].reject({status:409,code:'history-changed'});await first;assert.equal(c.state.record.identity.occurrence.image_ordinal,1);c.dispose();});
test('Viewer freezes selected occurrence and location entries through navigation and late timers',()=>{const a=adapter(),first=media('1',1),second=media('2',0,'d'.repeat(64)),items=[first,second],seen=[];const view=createViewerSession({adapter:a,items,initialKey:location,onChange:(state,item)=>seen.push([state,item])});first.occurrence.image_ordinal=9;first.reference.filename='mutated';items.reverse();assert.equal(seen[0][1].occurrence.image_ordinal,1);assert.equal(seen[0][1].reference.filename,'a #%.png');view.navigate(1);assert.equal(view.state.key,'d'.repeat(64));view.navigate(-1);assert.equal(seen.at(-1)[1].occurrence.image_ordinal,1);view.dispose();const count=seen.length;view.navigate(1);view.ready(location);view.play();assert.equal(seen.length,count);});
test('malformed occurrence echoes invalidate actions instead of producing retryable current facts',()=>{const a=adapter();for(const occurrence of [{run_id:run,output_node_id:'1',image_ordinal:'0'},{run_id:'bad',output_node_id:'1',image_ordinal:0},{}]){const data=envelope();data.current_output.occurrence=occurrence;assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),error=>error.refreshRequired===true && error.code==='candidate-stale');}});
test('stale inspection echoes use the canonical recovery vocabulary and identity status',()=>{
for(const [code,action] of [['history-changed','refresh-history'],['candidate-stale','reload-outputs']]) {
const data=envelope();
if(code==='history-changed')data.history_epoch='33333333-3333-3333-3333-333333333333';
else data.current_output.current_scope='d'.repeat(64);
assert.throws(()=>adapter().toRecord(data,media(),extractGenerationFacts),error=>{
assert.equal(error.code,code);assert.equal(error.status,409);assert.deepEqual(error.recovery,{action});return true;
});
}
});
+278
View File
@@ -0,0 +1,278 @@
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();
}
});
@@ -324,3 +324,20 @@ test('invalid attempted selections abort pending reads and suppress late results
controller.dispose();
}
});
test('explicit rooted adapter preserves the strict default identity and download contract', () => {
assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported');
const a = module.rootedFileAdapter, entry = image();
assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA);
assert.equal(a.inspectionPath(entry), module.inspectPath(entry));
assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry));
assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts));
assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError);
});
test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => {
const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts});
const selected={...image(),ignoredCallback(){}};
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
});
+126
View File
@@ -32,3 +32,129 @@ test('preferences handle denied malformed storage without mutation', () => {
raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null});
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
});
function mountedDom() {
class Node {
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
set textContent(value){this._text=String(value);this.children=[];}
get childNodes(){return this.children;}
get isConnected(){return this.root || !!this.parent?.isConnected;}
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
setAttribute(key,value){this.attributes[key]=String(value);}
getAttribute(key){return this.attributes[key]??null;}
removeAttribute(key){delete this.attributes[key];}
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
focus(){this.focused=true;}
showModal(){this.open=true;}
close(){this.open=false;this.fire('close');}
contains(node){return node===this||this.children.some(c=>c.contains(node));}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
pause(){this.paused=true;}
load(){this.loaded=true;}
play(){this.played=true;return Promise.resolve();}
}
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
const oldDocument=globalThis.document,oldWindow=globalThis.window;
const container=new Node('main');container.root=true;
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
globalThis.document=doc;globalThis.window=win;
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
}
function occurrenceMedia(node,ordinal,key) {
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;
return {kind:'reported-location',location_key:key.repeat(64),history_epoch:epoch,current_scope:scope,occurrence:{run_id:run,output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},filename:'a.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
}
function borrowedController() {
const listeners=new Set();let disposed=0;
return {get state(){return {loading:false,error:false,record:null};},subscribe(fn){listeners.add(fn);fn(this.state);return ()=>listeners.delete(fn);},publish(state){for(const fn of listeners)fn(state);},retry(){},dispose(){disposed++;},get disposed(){return disposed;},get listeners(){return listeners.size;}};
}
test('occurrence Viewer retains exact caller selection and synchronously closes without disposing borrowed controller',async()=>{
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');assert.ok(adapter);
const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),selections=[],closes=[],items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const trigger=f.doc.activeElement;
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onSelect:entry=>selections.push(entry.occurrence),onClose(...args){assert.equal(f.walk(f.container,n=>n.tagName==='dialog').length,0);assert.equal(controller.listeners,0);assert.equal(trigger.focused,true);closes.push(args);}});
try {
view.open({items,selectedKey:'c'.repeat(64),trigger});assert.deepEqual(selections,[{run_id:items[0].occurrence.run_id,output_node_id:'1',image_ordinal:1}]);
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
});
test('slideshow stops for detail reading nested send close and parent disposal with late media callbacks ignored',async()=>{
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];let closes=0,sends=0;
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onClose:()=>closes++,sender:{open(){sends++;}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
let media=f.walk(f.container,n=>n.tagName==='img')[0];media.fire('load');find('Play slideshow').fire('click');assert.ok(find('Pause slideshow'));
f.walk(f.container,n=>n.tagName==='aside')[0].fire('pointerdown');assert.ok(find('Play slideshow'),'explicit detail reading ends slideshow intent');
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
});
function currentInspectionEnvelope(target) {
return {success:true,inspection_version:1,history_version:1,history_epoch:target.history_epoch,
current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:target.history_epoch,current_scope:target.current_scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
}
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');
const {createInspectionController}=await import('../../web/js/workspace/inspection-state.mjs');
const {extractGenerationFacts}=(await import('../../web/js/comfy-metadata.js')).default;
const f=mountedDom(),parentScope=createScope(),calls=[],reads=[],selected=[],timers=new Map();let serial=0,closed=0,sent=0;
const oldSetTimeout=globalThis.setTimeout,oldClearTimeout=globalThis.clearTimeout;
globalThis.setTimeout=(callback,delay)=>{assert.equal(delay,3500);timers.set(++serial,callback);return serial;};
globalThis.clearTimeout=token=>timers.delete(token);
const controller=createInspectionController({adapter,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});
const items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d'),occurrenceMedia('3',0,'e')];
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,
onSelect(entry){selected.push(entry.occurrence);reads.push(controller.select(entry));},onClose(){closed++;},sender:{open(){sent++;}}});
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
const image=()=>f.walk(f.container,n=>n.tagName==='img')[0];
const advance=()=>{assert.equal(timers.size,1);const [token,callback]=timers.entries().next().value;timers.delete(token);callback();};
try {
view.open({items,selectedKey:items[0].location_key});assert.equal(controller.state.loading,true);
calls[0].resolve(currentInspectionEnvelope(items[0]));await reads[0];image().fire('load');find('Play slideshow').fire('click');
advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'),'automatic next-file inspection preserves intent');
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2']);assert.equal(timers.size,0,'readiness still gates the next advance');
image().fire('load');advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'));
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2','3']);assert.equal(calls[1].signal.aborted,true);
image().fire('load');assert.equal(timers.size,1);calls[1].reject(new Error('late superseded inspection'));await reads[1];assert.ok(find('Pause slideshow'));
calls[2].resolve(currentInspectionEnvelope(items[2]));await reads[2];assert.ok(find('Pause slideshow'));assert.equal(timers.size,1);
f.walk(f.container,n=>n.tagName==='aside')[0].fire('keydown');assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
find('Play slideshow').fire('click');f.walk(f.container,n=>n.dataset.sendRole==='pair')[0].fire('click');assert.equal(sent,1);assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
find('Play slideshow').fire('click');const retry=controller.retry();calls.at(-1).reject(new Error('inspection read failed'));await retry;
assert.ok(find('Play slideshow'),'inspection errors stop intent');assert.equal(timers.size,0);
const retryButton=f.walk(f.container,n=>n.textContent==='Retry prompt details')[0];assert.ok(retryButton);
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
});
+9
View File
@@ -108,3 +108,12 @@ test('slideshow cannot start on video one entry or media error', () => {
}
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);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2097,7 +2097,7 @@ class TestRootScopeServing(ImageAPITestCase):
side_effect=AssertionError("saved lookup"),
),
patch(
"py.api.images.read_inspection_file",
"py.api.inspection_service.read_inspection_file",
side_effect=AssertionError("metadata read"),
),
patch(
+2 -2
View File
@@ -347,7 +347,7 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(response.status, 200)
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
with patch(
"py.api.images.read_inspection_file",
"py.api.inspection_service.read_inspection_file",
side_effect=RuntimeError(str(self.comfy_dir)),
):
response, result = await self.inspect("image.jpg")
@@ -1044,7 +1044,7 @@ class TestPromptInspector(ImageAPITestCase):
self.api, "_file_response", side_effect=AssertionError("file response")
),
patch(
"py.api.images.read_inspection_file",
"py.api.inspection_service.read_inspection_file",
side_effect=AssertionError("metadata read"),
),
):
+35
View File
@@ -22,6 +22,41 @@ class HistoryRestoreTests(unittest.TestCase):
finish = history_tests.HistoryDatabaseTests.finish
count = history_tests.HistoryDatabaseTests.count
def test_restore_installs_optional_history_read_indexes_without_mutating_source(
self,
):
from database.execution_history_schema import history_schema_error
db = self.database()
start = self.start(db)
db.write_execution_start(start)
conn = db.model.get_connection()
tables = (
"execution_prompt_snapshots",
"execution_contexts",
"execution_run_contexts",
"execution_runs",
)
with conn:
for table in tables:
conn.execute(f"DROP INDEX idx_history_read_{table}")
self.assertIsNone(history_schema_error(conn, allow_missing=False))
source = str(Path(self.temp.name) / "without-read-indexes.db")
self.assertTrue(db.model.backup_database(source))
original = Path(source).read_bytes()
db.model.restore_from_file(source)
conn = db.model.get_connection()
for table in tables:
indexes = {r[1]: r for r in conn.execute(f"PRAGMA index_list({table})")}
self.assertIn(f"idx_history_read_{table}", indexes)
self.assertFalse(indexes[f"idx_history_read_{table}"][2])
self.assertIsNone(history_schema_error(conn, allow_missing=False))
self.assertEqual(Path(source).read_bytes(), original)
with db.recorded_history_snapshot() as epoch:
run = db.get_history_run(start.run_id)
self.assertNotEqual(epoch, run["run"]["storage_epoch"])
self.assertEqual(run["run"]["storage_epoch"], start.storage_epoch)
def test_output_operations_restore_preserves_evidence_and_rejects_stale_finish(
self,
):
+511
View File
@@ -0,0 +1,511 @@
"""Current locations require a native namespace and independent authorization."""
import importlib
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.test_api_images import make_png
from database.operations import PromptDatabase
from py.config import GalleryConfig
from utils.execution_outputs import snapshot_output_roots
class CurrentLocationTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name).resolve()
self.output = self.base / "output"
self.temp = self.base / "temp"
self.output.mkdir()
self.temp.mkdir()
self.roots = [self.output]
self.host_output = str(self.output)
self.host_temp = str(self.temp)
self.stack = ExitStack()
self.addCleanup(self.stack.close)
self.stack.enter_context(
patch(
"folder_paths.get_output_directory",
lambda: self.host_output,
create=True,
)
)
self.stack.enter_context(
patch(
"folder_paths.get_temp_directory", lambda: self.host_temp, create=True
)
)
self.stack.enter_context(
patch.object(
GalleryConfig, "allowed_gallery_parents", lambda: [str(self.base)]
)
)
self.stack.enter_context(
patch.object(GalleryConfig, "path_anchors", lambda: [str(self.base)])
)
self.api = SimpleNamespace(_get_all_output_dirs=lambda: list(self.roots))
def module(self, name="py.api.history_current"):
self.assertIsNotNone(
importlib.util.find_spec(name), f"missing current-location adapter {name}"
)
return importlib.import_module(name)
def reference(self, **overrides):
result = {
"folder_type": "output",
"subfolder": "",
"filename": "image.png",
"path_style": "windows" if os.name == "nt" else "posix",
"root_key": snapshot_output_roots(
"windows" if os.name == "nt" else "posix",
self.host_output,
self.host_temp,
).output_key,
}
result.update(overrides)
return result
def resolve(self, reference=None, gallery=None):
mod = self.module()
scope = mod.snapshot_current_output_scope(self.api)
return mod.resolve_current_candidate(
reference or self.reference(), scope, gallery or {}
).public
def test_lexical_alias_is_not_replaced_by_canonical_root_key(self):
mod = self.module()
path = make_png(self.output / "image.png")
alias = self.base / "alias"
alias.symlink_to(self.output, target_is_directory=True)
self.host_output = str(alias)
reference = self.reference()
scope = mod.snapshot_current_output_scope(self.api)
with patch.object(
Path, "stat", side_effect=AssertionError("locator performed IO")
):
self.assertEqual(
mod.candidate_locator(reference, scope),
os.path.normcase(str(alias / path.name)),
)
candidate = mod.resolve_current_candidate(reference, scope, {}).public
self.assertEqual(candidate["status"], "available")
self.assertEqual(candidate["gallery_match"], "none")
self.assertRegex(candidate["location_key"], r"^[0-9a-f]{64}$")
wrong = self.reference(
root_key=snapshot_output_roots(
"windows" if os.name == "nt" else "posix", str(self.output), None
).output_key
)
self.assertEqual(
mod.resolve_current_candidate(wrong, scope, {}).public["reason"],
"namespace_mismatch",
)
def test_null_foreign_relocated_and_unsafe_descriptors_have_allowed_control(self):
make_png(self.output / "image.png")
self.assertEqual(self.resolve()["status"], "available")
for changes, reason in (
({"root_key": None}, "root_unavailable"),
({"root_key": "a" * 64}, "namespace_mismatch"),
(
{
"path_style": "posix" if os.name == "nt" else "windows",
"subfolder": "sub",
"root_key": "b" * 64,
},
"foreign_path_style",
),
({"subfolder": "../outside"}, "namespace_mismatch"),
({"filename": "/absolute.png"}, "namespace_mismatch"),
):
with self.subTest(changes=changes):
candidate = self.resolve(self.reference(**changes))
self.assertEqual(
(candidate["status"], candidate["reason"]), ("unavailable", reason)
)
self.assertNotIn("media", candidate)
def test_temp_requires_current_gallery_authorization(self):
make_png(self.temp / "image.png")
reference = self.reference(
folder_type="temp",
root_key=snapshot_output_roots(
"windows" if os.name == "nt" else "posix",
self.host_output,
self.host_temp,
).temp_key,
)
self.assertEqual(self.resolve(reference)["reason"], "not_authorized")
self.roots.append(self.temp)
self.assertEqual(self.resolve(reference)["status"], "available")
def test_supported_regular_readable_control_and_distinct_failures(self):
path = make_png(self.output / "image.png")
self.assertEqual(self.resolve()["status"], "available")
path.unlink()
self.assertEqual(self.resolve()["reason"], "missing")
path.mkdir()
self.assertEqual(self.resolve()["reason"], "not_regular_file")
path.rmdir()
path.write_bytes(b"media")
with patch("os.access", return_value=False):
self.assertEqual(self.resolve()["reason"], "unreadable")
(self.output / "notes.txt").write_text("private")
self.assertEqual(
self.resolve(self.reference(filename="notes.txt"))["reason"],
"unsupported_media",
)
def test_symlink_escape_never_authorizes_another_namespace(self):
allowed = make_png(self.output / "image.png")
outside = make_png(self.temp / "outside.png")
self.roots.append(self.temp)
self.assertEqual(self.resolve()["status"], "available")
allowed.unlink()
allowed.symlink_to(outside)
self.assertEqual(self.resolve()["reason"], "not_authorized")
def test_scope_tracks_configuration_targets_and_policy_but_not_bytes(self):
mod = self.module()
path = make_png(self.output / "image.png")
first = mod.snapshot_current_output_scope(self.api)
location = mod.resolve_current_candidate(self.reference(), first, {}).public[
"location_key"
]
path.write_bytes(b"replacement")
same = mod.snapshot_current_output_scope(self.api)
self.assertEqual(same.current_scope, first.current_scope)
self.assertEqual(
mod.resolve_current_candidate(self.reference(), same, {}).public[
"location_key"
],
location,
)
self.roots.append(self.temp)
changed = mod.snapshot_current_output_scope(self.api)
self.assertNotEqual(changed.current_scope, first.current_scope)
self.roots.reverse()
self.assertEqual(
mod.snapshot_current_output_scope(self.api).current_scope,
changed.current_scope,
)
with patch.object(
GalleryConfig, "allowed_gallery_parents", lambda: [str(self.output)]
):
self.assertNotEqual(
mod.snapshot_current_output_scope(self.api).current_scope,
changed.current_scope,
)
alias = self.base / "alias"
alias.symlink_to(self.output, target_is_directory=True)
self.host_output = str(alias)
before = mod.snapshot_current_output_scope(self.api)
alias.unlink()
alias.symlink_to(self.temp, target_is_directory=True)
self.assertNotEqual(
mod.snapshot_current_output_scope(self.api).current_scope,
before.current_scope,
)
def test_gallery_projection_is_snapshot_owned_exact_and_ambiguous(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
path = make_png(self.output / "image.png")
locator = os.path.normcase(str(path))
prompt = db.save_prompt("first", prompt_hash="first-hash")
other = db.save_prompt("second", prompt_hash="second-hash")
image = db.link_image_to_prompt(prompt, str(path))
db.link_image_to_prompt(other, str(self.temp / path.name))
conn = db.model.get_connection()
with self.assertRaises(Exception):
mod.read_history_gallery(conn, (locator,))
with db.recorded_history_snapshot():
projected = mod.read_history_gallery(conn, (locator,))
self.assertTrue(conn.in_transaction)
self.assertEqual(projected[locator]["gallery_match"], "unique")
self.assertEqual(projected[locator]["gallery_image_id"], image)
db.link_image_to_prompt(other, str(path))
with db.recorded_history_snapshot():
projected = mod.read_history_gallery(conn, (locator,))
self.assertEqual(projected[locator]["gallery_match"], "ambiguous")
self.assertIsNone(projected[locator]["gallery_image_id"])
self.assertIsNone(projected[locator]["saved"])
self.assertEqual(projected[locator]["library"]["status"], "ambiguous")
with self.assertRaises(ValueError):
mod.read_history_gallery(
conn, tuple(locator + str(i) for i in range(101))
)
def test_gallery_display_is_bounded_without_loading_workflow_or_prompt(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
path = make_png(self.output / "image.png")
locator = os.path.normcase(str(path))
prompt = db.save_prompt(
"secret prompt" * 10000, prompt_hash="large-hash", category="猫" * 12000
)
image = db.link_image_to_prompt(prompt, str(path))
with db.model.get_connection() as conn:
conn.execute(
"UPDATE generated_images SET workflow_data=?,prompt_metadata=? "
"WHERE id=?",
("private" * 100000, "private" * 100000, image),
)
statements = []
conn.set_trace_callback(statements.append)
self.addCleanup(conn.set_trace_callback, None)
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
display = result[locator]["library"]
self.assertEqual(display["status"], "limited")
self.assertEqual(display["tags"], [])
self.assertFalse(
any(
"workflow_data" in sql or "prompt_metadata" in sql or "p.text" in sql
for sql in statements
)
)
self.assertFalse(any(sql.startswith("COMMIT") for sql in statements))
def test_gallery_queries_stop_after_two_owners_and_two_pair_associations(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
path = make_png(self.output / "image.png")
unique = make_png(self.output / "unique.png")
locator = os.path.normcase(str(path))
unique_locator = os.path.normcase(str(unique))
prompt = db.save_prompt("owner", prompt_hash="owner")
image = db.link_image_to_prompt(prompt, str(unique))
with db.model.get_connection() as conn:
for i in range(12):
owner = conn.execute(
"INSERT INTO prompts(text,hash) VALUES(?,?)",
(str(i), str(i)),
).lastrowid
pair = conn.execute(
"INSERT INTO prompt_pairs"
"(positive_prompt_id,negative_prompt_id,title) "
"VALUES(?,?,?)",
(prompt, owner, str(i)),
).lastrowid
conn.execute(
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
"VALUES(?,?,'{}')",
(image, pair),
)
conn.execute(
"INSERT INTO generated_images"
"(prompt_id,image_path,filename,file_path) "
"VALUES(?,?,'image.png',?)",
(owner, str(path), locator),
)
fetched = {"owners": 0, "pairs": 0}
def row_factory(cursor, values):
names = {column[0] for column in cursor.description}
if names == {"id", "prompt_id", "category"}:
fetched["owners"] += 1
elif names == {"id", "positive_prompt_id", "title", "category"}:
fetched["pairs"] += 1
return sqlite3.Row(cursor, values)
conn.row_factory = row_factory
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator, unique_locator))
self.assertEqual(fetched, {"owners": 3, "pairs": 2})
self.assertEqual(result[locator]["gallery_match"], "ambiguous")
self.assertEqual(result[unique_locator]["gallery_match"], "unique")
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
def test_host_root_failure_and_removal_do_not_search_another_root(self):
make_png(self.output / "image.png")
make_png(self.temp / "image.png")
self.roots.append(self.temp)
reference = self.reference()
self.assertEqual(self.resolve(reference)["status"], "available")
with patch("folder_paths.get_output_directory", side_effect=OSError("private")):
self.assertEqual(self.resolve(reference)["reason"], "root_unavailable")
(self.output / "image.png").unlink()
self.output.rmdir()
self.assertEqual(self.resolve(reference)["reason"], "root_unavailable")
def test_unusable_lexical_namespace_changes_still_change_scope(self):
mod = self.module()
make_png(self.output / "image.png")
reference = self.reference()
self.assertEqual(self.resolve(reference)["status"], "available")
self.host_output = "relative-one"
first = mod.snapshot_current_output_scope(self.api)
self.assertEqual(
mod.resolve_current_candidate(reference, first, {}).public["reason"],
"root_unavailable",
)
self.host_output = "relative-two"
self.assertNotEqual(
mod.snapshot_current_output_scope(self.api).current_scope,
first.current_scope,
)
def test_duplicate_owner_lookup_does_not_sort_the_entire_saved_population(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
path = os.path.normcase(str(self.output / "image.png"))
with db.model.get_connection() as conn:
for item in range(512):
owner = conn.execute(
"INSERT INTO prompts(text) VALUES(?)", (str(item),)
).lastrowid
conn.execute(
"INSERT INTO generated_images"
"(prompt_id,image_path,filename,file_path) "
"VALUES(?,?,'image.png',?)",
(owner, path, path),
)
steps = 0
def bounded():
nonlocal steps
steps += 1
return int(steps > 20)
conn.set_progress_handler(bounded, 100)
try:
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (path,))
except sqlite3.OperationalError as error:
self.fail(f"Two-owner lookup exceeded bounded query work: {error}")
finally:
conn.set_progress_handler(None, 0)
self.assertEqual(result[path]["gallery_match"], "ambiguous")
def test_two_same_pair_links_and_later_different_pair_withhold_pair_choice(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
path = make_png(self.output / "image.png")
locator = os.path.normcase(str(path))
pair = db.save_prompt_pair("owner", "negative one")
other_pair = db.save_prompt_pair("owner", "negative two")
owner = db.get_prompt_pair(pair)["positive_prompt_id"]
image = db.link_image_to_prompt(owner, str(path))
with db.model.get_connection() as conn:
for pair_id, provenance in (
(pair, "{}"),
(pair, '{"source":"second"}'),
(other_pair, "{}"),
):
conn.execute(
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
"VALUES(?,?,?)",
(image, pair_id, provenance),
)
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))[locator]
self.assertEqual(result["gallery_match"], "unique")
self.assertEqual(result["gallery_image_id"], image)
self.assertEqual(result["library"]["status"], "limited")
self.assertIsNone(result["library"].get("pair_id"))
self.assertIsNone(result["saved"].get("pair"))
def test_inspection_rejects_unsupported_suffix_before_parser(self):
service = self.module("py.api.inspection_service")
path = make_png(self.output / "image.png")
observed = service.inspect_current_file(
path, source_label="output", saved=None, library={"status": "none"}
)
self.assertEqual(observed["file"]["media_type"], "image")
self.assertEqual(observed["inspection"].format, "PNG")
unsupported = self.output / "image.txt"
unsupported.write_bytes(path.read_bytes())
read = service.read_inspection_file
reads = []
def traced(target):
reads.append(target)
return read(target)
with patch.object(service, "read_inspection_file", side_effect=traced):
with self.assertRaises(service.CurrentFileChanged):
service.inspect_current_file(
unsupported,
source_label="output",
saved=None,
library={"status": "none"},
)
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
class HistoryImportTests(unittest.TestCase):
def test_api_import_does_not_require_a_running_host_server(self):
script = "; ".join(
(
"import sys, types",
"sys.modules['folder_paths'] = types.ModuleType('folder_paths')",
"import py.api",
)
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_minimal_host_fixture_restores_getters_after_real_route_control(self):
program = """
import io
from pathlib import Path
import sys
import types
import unittest
host = types.ModuleType("folder_paths")
original = lambda: "unused-minimal-host-output"
host.get_output_directory = original
sys.modules["folder_paths"] = host
from tests.test_api_history_outputs import HistoryOutputAPITests
case = HistoryOutputAPITests(
"test_complete_zero_incomplete_unavailable_and_not_captured_are_distinct"
)
try:
runner = unittest.TextTestRunner(stream=io.StringIO())
result = runner.run(unittest.TestSuite([case]))
assert host.get_output_directory is original, "fixture leaked host getter"
assert not hasattr(host, "get_temp_directory"), "fixture leaked optional getter"
assert result.wasSuccessful(), result.errors
finally:
if hasattr(case, "api"):
case.api.db.close_all()
if hasattr(case, "_anchor_patch"):
case._anchor_patch.stop()
if hasattr(case, "_temp_db"):
for suffix in ("", "-wal", "-shm"):
Path(case._temp_db.name + suffix).unlink(missing_ok=True)
if hasattr(case, "_output_tmp"):
case._output_tmp.cleanup()
"""
result = subprocess.run(
[sys.executable, "-c", program],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
+203
View File
@@ -0,0 +1,203 @@
"""Fresh epoch validation survives ended snapshots and retired API owners."""
import importlib
import sqlite3
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from database.execution_history import HistoryEpochChanged, notify_history_restore
from tests import test_execution_history_database as history_tests
class HistoryReadFenceTests(unittest.TestCase):
setUp = history_tests.HistoryDatabaseTests.setUp
close_databases = history_tests.HistoryDatabaseTests.close_databases
database = history_tests.HistoryDatabaseTests.database
start = history_tests.HistoryDatabaseTests.start
def fence(self, db):
try:
module = importlib.import_module("utils.history_read_fence")
except ImportError:
self.fail("HistoryReadFence must protect publication of history reads")
fence = module.HistoryReadFence(db)
self.addCleanup(fence.close)
return fence
def snapshot_file(self, db):
path = str(Path(self.temp.name) / "source.db")
self.assertTrue(db.model.backup_database(path))
return path
def test_final_epoch_is_fresh_after_snapshot_even_before_restore_notification(self):
reader = self.database()
writer = self.database()
start = self.start(reader)
reader.write_execution_start(start)
source = self.snapshot_file(reader)
fence = self.fence(reader)
generation = fence.begin()
with reader.recorded_history_snapshot() as epoch:
original = reader.get_history_run(start.run_id)
with patch("database.execution_history.notify_history_restore"):
writer.model.restore_from_file(source)
self.assertEqual(reader.get_execution_epoch(), epoch)
self.assertEqual(original["run"]["storage_epoch"], epoch)
with self.assertRaises(HistoryEpochChanged):
fence.validate(epoch, generation)
self.assertEqual(fence.begin(), generation)
def test_restore_observer_rejects_same_epoch_even_when_storage_did_not_change(self):
db = self.database()
fence = self.fence(db)
generation = fence.begin()
epoch = db.get_execution_epoch()
notify_history_restore(self.path)
with self.assertRaises(HistoryEpochChanged):
fence.validate(epoch, generation)
self.assertGreater(fence.begin(), generation)
fence.validate(epoch, fence.begin())
def test_new_read_before_delayed_notification_can_return_coherent_new_epoch(self):
db = self.database()
writer = self.database()
fence = self.fence(db)
old_epoch = db.get_execution_epoch()
source = self.snapshot_file(db)
with patch("database.execution_history.notify_history_restore"):
writer.model.restore_from_file(source)
generation = fence.begin()
with db.recorded_history_snapshot() as epoch:
self.assertNotEqual(epoch, old_epoch)
self.assertIsNone(
db.get_history_run("11111111-1111-4111-8111-111111111111")
)
fence.validate(epoch, generation)
notify_history_restore(self.path)
with self.assertRaises(HistoryEpochChanged):
fence.validate(epoch, generation)
def test_restore_after_final_validation_is_later_staleness_not_relabeling(self):
db = self.database()
writer = self.database()
start = self.start(db)
db.write_execution_start(start)
source = self.snapshot_file(db)
fence = self.fence(db)
generation = fence.begin()
with db.recorded_history_snapshot() as epoch:
detail = db.get_history_run(start.run_id)
fence.validate(epoch, generation)
writer.model.restore_from_file(source)
self.assertEqual(detail["run"]["storage_epoch"], epoch)
self.assertNotEqual(writer.get_execution_epoch(), epoch)
def test_validation_does_not_reopen_retired_owner_and_closes_read_only_handle(self):
db = self.database()
epoch = db.get_execution_epoch()
fence = self.fence(db)
generation = fence.begin()
db.close_all()
module = importlib.import_module("utils.history_read_fence")
connect = sqlite3.connect
opened = []
def tracked(*args, **kwargs):
conn = connect(*args, **kwargs)
with self.assertRaises(sqlite3.OperationalError):
conn.execute(
"UPDATE execution_history_metadata SET storage_epoch=storage_epoch"
)
opened.append(conn)
return conn
with patch.object(
db.model, "get_connection", side_effect=AssertionError("retired")
):
with patch.object(module.sqlite3, "connect", side_effect=tracked):
fence.validate(epoch, generation)
self.assertEqual(len(opened), 1)
with self.assertRaises(sqlite3.ProgrammingError):
opened[0].execute("SELECT 1")
self.assertEqual(len(db.model._connections), 0)
def test_observer_or_close_between_epoch_select_and_publish_rejects_read(self):
db = self.database()
module = importlib.import_module("utils.history_read_fence")
original_connect = sqlite3.connect
epoch = db.get_execution_epoch()
database_path = self.path
for close in (False, True):
fence = self.fence(db)
generation = fence.begin()
class InterruptedConnection(sqlite3.Connection):
def close(self):
super().close()
if close:
fence.close()
else:
notify_history_restore(database_path)
def interrupted_connect(*args, **kwargs):
return original_connect(*args, factory=InterruptedConnection, **kwargs)
with patch.object(
module.sqlite3, "connect", side_effect=interrupted_connect
):
with self.assertRaises(sqlite3.Error if close else HistoryEpochChanged):
fence.validate(epoch, generation)
def test_final_select_failure_always_closes_its_handle(self):
db = self.database()
fence = self.fence(db)
module = importlib.import_module("utils.history_read_fence")
epoch = db.get_execution_epoch()
with db.model.get_connection() as conn:
conn.execute("DROP TABLE execution_history_metadata")
original_connect = sqlite3.connect
opened = []
def tracked(*args, **kwargs):
conn = original_connect(*args, **kwargs)
opened.append(conn)
return conn
with patch.object(module.sqlite3, "connect", side_effect=tracked):
with self.assertRaises(sqlite3.DatabaseError):
fence.validate(epoch, fence.begin())
self.assertEqual(len(opened), 1)
with self.assertRaises(sqlite3.ProgrammingError):
opened[0].execute("SELECT 1")
def test_missing_metadata_and_connection_failure_are_not_empty_success(self):
db = self.database()
fence = self.fence(db)
generation = fence.begin()
epoch = db.get_execution_epoch()
with closing(sqlite3.connect(self.path)) as conn:
conn.execute("DELETE FROM execution_history_metadata")
conn.commit()
with self.assertRaises(sqlite3.DatabaseError):
fence.validate(epoch, generation)
module = importlib.import_module("utils.history_read_fence")
with patch.object(
module.sqlite3, "connect", side_effect=OSError("private/path")
):
with self.assertRaises(OSError):
fence.validate(epoch, generation)
def test_close_is_idempotent_unsubscribes_and_permanently_invalidates_reads(self):
db = self.database()
fence = self.fence(db)
epoch = db.get_execution_epoch()
generation = fence.begin()
fence.close()
fence.close()
notify_history_restore(self.path)
with self.assertRaises(sqlite3.Error):
fence.begin()
with self.assertRaises(sqlite3.Error):
fence.validate(epoch, generation)
+915
View File
@@ -0,0 +1,915 @@
"""Bounded chronological projections preserve frozen evidence and exact coordinates."""
import hashlib
import json
import sqlite3
import unittest
from dataclasses import replace
from unittest.mock import patch
from database.execution_history import HistoryEpochChanged
from database.execution_history_schema import history_schema_error
from utils.execution_contexts import ContextBundle, RecipeContext, TextEvidence
from utils.execution_outputs import OutputBundle
from tests.test_execution_context_database import bundle, snapshot
from tests.test_execution_output_database import bundle as outputs
from tests import test_execution_history_database as history_tests
class HistoryReadTests(unittest.TestCase):
setUp = history_tests.HistoryDatabaseTests.setUp
close_databases = history_tests.HistoryDatabaseTests.close_databases
database = history_tests.HistoryDatabaseTests.database
start = history_tests.HistoryDatabaseTests.start
finish = history_tests.HistoryDatabaseTests.finish
def saved(self, db, text="positive", identity=1):
with db.model.get_connection() as conn:
conn.execute("INSERT INTO prompts(id,text) VALUES(?,?)", (identity, text))
return identity
def capture(self, db, item=0, context=None, output=None, time=100):
start = self.start(db, item, time)
db.write_execution_start(start)
db.write_execution_finish(self.finish(start))
if context is not False:
db.write_execution_contexts(
start.run_id, start.storage_epoch, context or bundle()
)
if output is not None:
db.write_execution_outputs_for_finish(self.finish(start), output)
return start
def require_reads(self, db):
self.assertTrue(
callable(getattr(db, "recorded_history_snapshot", None)),
"PromptDatabase must expose bounded history reads",
)
def test_aab_distinct_runs_exact_raw_scope(self):
db = self.database()
self.saved(db)
self.saved(db, identity=2)
with db.model.get_connection() as conn:
conn.execute("UPDATE prompts SET run_count=31")
conn.execute(
"INSERT INTO generated_images(prompt_id,image_path,filename) "
"VALUES(1,'/first','first'),(2,'/second','second')"
)
a = bundle("A").contexts[0]
b = bundle("B").contexts[0]
starts = [
self.capture(
db, 0, replace(bundle("A"), contexts=(a, replace(a, anchor_id="5")))
),
self.capture(
db, 1, replace(bundle("A"), contexts=(a, replace(b, anchor_id="6")))
),
self.capture(db, 2, bundle("B"), outputs()),
]
conn = db.model.get_connection()
tables = ("prompts", "generated_images", "prompt_pairs", "image_prompt_pairs")
before = {t: list(conn.execute(f"SELECT * FROM {t}")) for t in tables}
self.require_reads(db)
with db.recorded_history_snapshot():
page = db.list_history_window(1)
duplicate = db.list_history_window(2)
self.assertEqual(page["recorded_run_count"], 3)
self.assertEqual(
[r["run_id"] for r in page["items"]], [s.run_id for s in starts]
)
self.assertEqual(len(page["context_summaries"]), 2)
self.assertEqual(
page["group"]["raw_text_id"], duplicate["group"]["raw_text_id"]
)
self.assertEqual(len(page["items"][0]["matching_anchors"]), 2)
self.assertEqual(
len(page["context_summaries"][0]["negative_raw"]["evidence"]), 3
)
self.assertEqual(
page["items"][0]["output_summary"]["list_status"], "not_captured"
)
self.assertEqual(
page["items"][2]["output_summary"]["retained_occurrence_count"], 1
)
self.assertEqual(
before, {t: list(conn.execute(f"SELECT * FROM {t}")) for t in tables}
)
def test_raw_scope_distinctions_and_eligibility(self):
db = self.database()
values = (
"positive",
"Positive",
" positive",
"positive ",
"é",
"e\u0301",
"",
" ",
)
for i, raw in enumerate(values, 1):
self.saved(db, raw, i)
self.capture(db, i, bundle(positive=raw))
modified = replace(
snapshot("positive"),
effective=snapshot("other").raw,
prepend=snapshot("prefix").raw,
)
self.capture(
db,
50,
replace(
bundle(),
contexts=(RecipeContext("4", modified, snapshot(""), "inferred"),),
),
)
unknown = replace(
snapshot("positive"),
raw=TextEvidence(None, "unknown", "unknown", "missing"),
)
self.capture(
db,
51,
ContextBundle(
(RecipeContext("4", unknown, snapshot(""), "inferred"),),
"partial",
"captured",
),
)
self.saved(db, "x" * 65537, 99)
self.saved(db, "negative-only", 100)
with db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) "
"VALUES(1,100)"
)
self.require_reads(db)
with db.recorded_history_snapshot():
for i in range(1, len(values) + 1):
self.assertEqual(
db.list_history_window(i)["recorded_run_count"], 2 if i == 1 else 1
)
self.assertIsNone(db.list_history_window(100))
self.assertIsNone(db.list_history_window(101))
page = db.list_history_window(99)
self.assertEqual(page["group"]["scope_status"], "uncapturable_text")
self.assertIsNone(page["group"]["raw_text_id"])
self.assertEqual(page["recorded_run_count"], 0)
def test_prefix_is_sql_bounded_and_nul_safe(self):
db = self.database()
self.saved(db)
negative = "A\0猫😀" + "z" * 20000
self.capture(db, context=bundle(negative))
self.capture(db, 1, bundle("A" + "😀" * 128))
self.require_reads(db)
traced = []
conn = db.model.get_connection()
conn.set_trace_callback(traced.append)
self.addCleanup(conn.set_trace_callback, None)
original_factory = conn.row_factory
prefix_results = []
def bounded_rows(cursor, values):
names = [column[0] for column in cursor.description]
if "prefix" in names:
prefix = values[names.index("prefix")]
prefix_results.append(prefix)
self.assertLessEqual(len(prefix), 512)
return sqlite3.Row(cursor, values)
conn.row_factory = bounded_rows
self.addCleanup(setattr, conn, "row_factory", original_factory)
with patch.object(
db, "get_execution_contexts", side_effect=AssertionError("full reader")
):
with db.recorded_history_snapshot():
page = db.list_history_window(1)
raw = page["context_summaries"][0]["negative_raw"]
self.assertEqual(len(prefix_results), 2)
self.assertEqual(
page["context_summaries"][1]["negative_raw"]["preview"],
("A" + "😀" * 128)[:100],
)
self.assertEqual(raw["preview"], negative[:100])
self.assertLessEqual(len(raw["preview"].encode()), 512)
self.assertTrue(raw["truncated"])
text_queries = [s.lower().replace(" ", "") for s in traced if "text_value" in s]
self.assertTrue(
any("substr(cast(" in s and "asblob),1,512)" in s for s in text_queries)
)
self.assertFalse(
any(
"selectr.text_value" in s or "select*fromexecution_context_texts" in s
for s in text_queries
)
)
def test_empty_unknown_and_per_anchor_evidence(self):
db = self.database()
self.saved(db)
a = bundle("").contexts[0]
cached = replace(
a,
negative=replace(
a.negative, raw=replace(a.negative.raw, source="host_cache_output")
),
)
self.capture(db, 0, replace(bundle(""), contexts=(a,)))
self.capture(db, 1, replace(bundle(""), contexts=(cached,)))
self.capture(
db, 2, ContextBundle((replace(a, negative=None),), "partial", "captured")
)
self.require_reads(db)
with db.recorded_history_snapshot():
summaries = db.list_history_window(1)["context_summaries"]
raw = summaries[0]["negative_raw"]
self.assertEqual(
(raw["state"], raw["preview"], raw["truncated"]),
("explicit_empty", "", False),
)
self.assertEqual(
{e["source"] for e in raw["evidence"]},
{"runtime_return", "host_cache_output"},
)
unknown = summaries[1]["negative_raw"]
self.assertEqual(
(unknown["state"], unknown["preview"], unknown["truncated"]),
("unknown", None, False),
)
def test_four_key_paging_upper_bound_and_vacuum(self):
db = self.database()
self.saved(db)
starts = [self.capture(db, i, time=2**53 + 7) for i in range(7)]
self.require_reads(db)
with db.recorded_history_snapshot():
page = db.list_history_window(1, limit=2)
self.assertEqual([r["queue_item_id"] for r in page["items"]], [5, 6])
self.assertTrue(page["has_more"])
self.capture(db, 8, time=2**53 + 8)
db.model.get_connection().execute("VACUUM")
ids = [r["run_id"] for r in page["items"]]
while page["has_more"]:
with db.recorded_history_snapshot():
page = db.list_history_window(1, limit=2, after=page["next_position"])
self.assertEqual(page["recorded_run_count"], 7)
ids = [r["run_id"] for r in page["items"]] + ids
self.assertEqual(ids, [s.run_id for s in starts])
with db.model.get_connection() as conn:
conn.execute("UPDATE prompts SET text='changed' WHERE id=1")
from database.history_reads import WindowPosition
with db.recorded_history_snapshot():
with self.assertRaises(ValueError):
db.list_history_window(
1,
after=WindowPosition(
"0" * 64,
(1, starts[0].session_id, 0, starts[0].run_id),
(1, starts[0].session_id, 0, starts[0].run_id),
),
)
def test_restart_is_upper_inclusive_with_fresh_rows_and_the_same_bounded_query_cost(
self,
):
db = self.database()
self.saved(db)
starts = [self.capture(db, i, time=i) for i in range(45)]
conn = db.model.get_connection()
trace = []
conn.set_trace_callback(trace.append)
with db.recorded_history_snapshot():
first = db.list_history_window(1)
self.assertIn("resume_position", first)
baseline_queries = len([sql for sql in trace if sql.startswith("SELECT")])
conn.set_trace_callback(None)
for i in range(45, 106):
self.capture(db, i, time=i)
trace.clear()
conn.set_trace_callback(trace.append)
try:
with db.recorded_history_snapshot():
resumed = db.list_history_window(
1, after=first["resume_position"], restart=True
)
finally:
conn.set_trace_callback(None)
self.assertEqual(resumed["recorded_run_count"], 45)
self.assertEqual(
[run["run_id"] for run in resumed["items"]],
[start.run_id for start in starts[5:]],
)
self.assertLessEqual(
len([sql for sql in trace if sql.startswith("SELECT")]), baseline_queries
)
run_queries = [
sql for sql in trace if sql.startswith("SELECT r.* FROM execution_runs")
]
self.assertEqual(len(run_queries), 1)
self.assertIn("LIMIT 41", run_queries[0])
plan = list(conn.execute("EXPLAIN QUERY PLAN " + run_queries[0]))
self.assertTrue(any("INDEX" in row[3] for row in plan), plan)
with db.recorded_history_snapshot():
earlier = db.list_history_window(1, after=resumed["next_position"])
self.assertEqual(
[run["run_id"] for run in earlier["items"]],
[start.run_id for start in starts[:5]],
)
self.assertFalse(earlier["has_more"])
self.assertEqual(earlier["resume_position"], first["resume_position"])
def test_restart_requires_a_complete_authenticated_style_upper_position_before_sql(
self,
):
from database.history_reads import WindowPosition
db = self.database()
self.saved(db)
self.capture(db)
with db.recorded_history_snapshot():
page = db.list_history_window(1)
self.assertIn("resume_position", page)
position = page["resume_position"]
for options in (
{"restart": True},
{"after": position, "restart": 1},
{
"after": WindowPosition(
position.raw_text_id,
position.upper,
(0, position.upper[1], 0, position.upper[3]),
),
"restart": True,
},
):
self.assert_position_rejected_before_sql(
db, lambda: db.list_history_window(1, **options)
)
def test_snapshot_epoch_first_ownership_and_rollback(self):
db = self.database()
self.require_reads(db)
conn = db.model.get_connection()
for read in (
lambda: db.list_history_window(1),
lambda: db.get_history_run("missing"),
lambda: db.list_history_outputs("missing"),
lambda: db.get_history_occurrence("missing", "9", 0),
):
with self.assertRaises(sqlite3.ProgrammingError):
read()
conn.execute("BEGIN")
with self.assertRaises(sqlite3.ProgrammingError):
with db.recorded_history_snapshot():
pass
self.assertTrue(conn.in_transaction)
conn.rollback()
trace = []
conn.set_trace_callback(trace.append)
def abort_snapshot() -> None:
with db.recorded_history_snapshot() as epoch:
self.assertEqual(epoch, db.get_execution_epoch())
db.get_execution_contexts("missing")
db.get_execution_outputs("missing")
self.assertTrue(conn.in_transaction)
raise RuntimeError("abort")
try:
self.assertRaisesRegex(RuntimeError, "abort", abort_snapshot)
finally:
conn.set_trace_callback(None)
self.assertIn("storage_epoch", next(q for q in trace if q.startswith("SELECT")))
self.assertFalse(conn.in_transaction)
with self.assertRaises(HistoryEpochChanged):
with db.recorded_history_snapshot("wrong"):
pass
self.assertFalse(conn.in_transaction)
def test_detail_preserves_full_text_snapshots_hints_and_archive(self):
db = self.database()
self.saved(db)
self.saved(db, "negative", 2)
with db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
"VALUES(7,1,2)"
)
start = self.capture(db, output=outputs())
legacy = db.get_execution_contexts(start.run_id)
self.require_reads(db)
with db.recorded_history_snapshot():
detail = db.get_history_run(start.run_id)
self.assertIsNone(db.get_history_run("missing"))
c = detail["contexts"][0]
self.assertTrue(c["positive_snapshot_id"])
self.assertTrue(c["negative_snapshot_id"])
self.assertEqual(c["pair_id"], 7)
self.assertEqual(
c["navigation"],
dict(positive_available=True, negative_available=True, pair_available=True),
)
self.assertEqual(c["positive"], legacy["contexts"][0]["positive"])
db.archive_prompt_pair(7)
with db.recorded_history_snapshot():
c = db.get_history_run(start.run_id)["contexts"][0]
self.assertEqual(c["pair_id"], 7)
self.assertFalse(c["navigation"]["pair_available"])
with db.model.get_connection() as conn:
conn.execute(
"UPDATE execution_history_metadata SET storage_epoch=?",
("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",),
)
with db.recorded_history_snapshot():
detail = db.get_history_run(start.run_id)
self.assertEqual(detail["run"]["storage_epoch"], start.storage_epoch)
self.assertEqual(detail["contexts"][0]["reference_epoch"], start.storage_epoch)
self.assertIsNone(detail["contexts"][0]["positive_prompt_id"])
def test_output_coordinates_fingerprint_and_capture_states(self):
db = self.database()
ref = outputs().references[0]
refs = tuple(
replace(ref, output_node_id=n, image_ordinal=i)
for n, i in (("2", 0), ("9", 0), ("9", 1), ("😀", 0))
)
start = self.capture(db, output=replace(outputs(), references=refs))
self.require_reads(db)
with db.recorded_history_snapshot():
page = db.list_history_outputs(start.run_id, limit=2)
last = db.list_history_outputs(
start.run_id, limit=2, after=page["next_position"]
)
one = db.get_history_occurrence(start.run_id, "9", 0)
self.assertIsNone(db.get_history_occurrence(start.run_id, "9", 8))
self.assertIsNone(db.list_history_outputs("missing"))
items = page["items"] + last["items"]
self.assertEqual(
[
(i["occurrence"]["output_node_id"], i["occurrence"]["image_ordinal"])
for i in items
],
[(r.output_node_id, r.image_ordinal) for r in refs],
)
self.assertEqual(one["receipt"], items[1])
self.assertEqual(one["capture"], page["capture"])
self.assertEqual(page["retained_occurrence_count"], 4)
self.assertEqual(
page["capture_fingerprint"],
hashlib.sha256(
json.dumps(
[1, "complete", "reported", 1, "host_final_history", 4],
separators=(",", ":"),
).encode()
).hexdigest(),
)
self.assertEqual(items[0]["file_identity"], "unverified")
self.assertEqual(items[0]["context_membership"], "unknown")
db.write_execution_outputs_for_finish(
self.finish(start), outputs("different.png")
)
with db.recorded_history_snapshot():
from database import history_reads
changed = getattr(history_reads, "HistoryCaptureChanged", None)
self.assertTrue(
isinstance(changed, type), "capture drift needs a distinct exception"
)
self.assertTrue(issubclass(changed, ValueError))
with self.assertRaises(changed):
db.list_history_outputs(start.run_id, after=page["next_position"])
self.assertEqual(
db.list_history_outputs(start.run_id)["list_status"], "incomplete"
)
for index, capture in enumerate(
(
None,
OutputBundle((), "complete", "none_reported"),
OutputBundle((), "unavailable", "unavailable"),
replace(outputs(), state="partial", reason="unsupported"),
),
1,
):
s = self.capture(db, index, output=capture)
with db.recorded_history_snapshot():
p = db.list_history_outputs(s.run_id)
self.assertEqual(
p["retained_occurrence_count"],
None if capture is None else len(capture.references),
)
self.assertEqual(
p["list_status"],
(
"not_captured"
if capture is None
else "complete" if capture.state == "complete" else "incomplete"
),
)
self.assertFalse(p["has_more"])
def test_limits_empty_group_and_orphan_validation(self):
db = self.database()
self.saved(db)
s = self.capture(db, context=False)
self.require_reads(db)
with db.recorded_history_snapshot():
p = db.list_history_window(1)
self.assertEqual(p["group"]["scope_status"], "no_matches")
self.assertEqual(p["items"], [])
self.assertEqual(
db.get_history_run(s.run_id)["context_capture"]["state"], "not_captured"
)
for limit in (0, 101, True, 1.5):
with self.assertRaises(ValueError):
db.list_history_window(1, limit=limit)
with self.assertRaises(ValueError):
db.list_history_outputs(s.run_id, limit=limit)
self.capture(db, 1, output=outputs())
conn = db.model.get_connection()
conn.execute("PRAGMA foreign_keys=OFF")
self.addCleanup(conn.execute, "PRAGMA foreign_keys=ON")
with conn:
conn.execute("DELETE FROM execution_output_capture")
with db.recorded_history_snapshot():
with self.assertRaises(sqlite3.DatabaseError):
db.list_history_outputs(self.start(db, 1).run_id)
def test_query_indexes_additive_and_reinitializable(self):
db = self.database()
conn = db.model.get_connection()
self.require_reads(db)
expected = {
"execution_prompt_snapshots": ("raw_text_id", "snapshot_id"),
"execution_contexts": ("positive_snapshot_id", "context_id"),
"execution_run_contexts": ("context_id", "run_id", "anchor_id"),
"execution_runs": (
"observed_dequeue_at",
"session_id",
"queue_item_id",
"run_id",
),
}
for table, columns in expected.items():
indexes = [
r for r in conn.execute(f"PRAGMA index_list({table})") if not r[2]
]
self.assertTrue(
any(
tuple(r[2] for r in conn.execute(f"PRAGMA index_info({i[1]})"))
== columns
for i in indexes
),
table,
)
self.assertIsNone(history_schema_error(conn, allow_missing=False))
from database.execution_history_schema import migrate_execution_history
for table in expected:
conn.execute(f"DROP INDEX idx_history_read_{table}")
# Optional read indexes are not an owned-schema validity requirement.
self.assertIsNone(history_schema_error(conn, allow_missing=False))
migrate_execution_history(conn)
migrate_execution_history(conn)
self.assertTrue(
all(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name=?",
(f"idx_history_read_{t}",),
).fetchone()
for t in expected
)
)
def test_epoch_first_snapshot_excludes_concurrent_finish(self):
db = self.database()
self.saved(db)
start = self.start(db)
db.write_execution_start(start)
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
peer = self.database()
before = db.model.get_connection().total_changes
with db.recorded_history_snapshot():
peer.write_execution_finish(self.finish(start))
self.assertEqual(
db.get_history_run(start.run_id)["run"]["outcome"], "observed"
)
page = db.list_history_window(1)
self.assertEqual(page["items"][0]["outcome"], "observed")
self.assertIsNone(page["items"][0]["observed_callback_at"])
with db.recorded_history_snapshot():
self.assertEqual(
db.get_history_run(start.run_id)["run"]["outcome"], "succeeded"
)
self.assertEqual(db.model.get_connection().total_changes, before)
def test_compact_diagnostics_full_detail_and_no_filesystem(self):
db = self.database()
self.saved(db)
start = self.start(db)
db.write_execution_start(start)
db.write_execution_finish(
self.finish(
start,
"execution_error",
node_id="4",
node_type="Example",
exception_type="ValueError",
)
)
db.write_execution_contexts(
start.run_id, start.storage_epoch, bundle("猫" * 20000)
)
conn = db.model.get_connection()
before = conn.total_changes
with (
patch("builtins.open", side_effect=AssertionError("filesystem")),
patch("os.stat", side_effect=AssertionError("filesystem")),
):
with db.recorded_history_snapshot():
page = db.list_history_window(1)
detail = db.get_history_run(start.run_id)
db.list_history_outputs(start.run_id)
db.get_history_occurrence(start.run_id, "9", 0)
compact = page["items"][0]
self.assertTrue(compact["diagnostics_available"])
self.assertNotIn("node_id", compact)
self.assertEqual(detail["run"]["node_id"], "4")
self.assertEqual(detail["contexts"][0]["negative"]["raw"], "猫" * 20000)
self.assertEqual(conn.total_changes, before)
def test_all_four_order_keys_across_sessions_and_run_ids(self):
db = self.database()
self.saved(db)
conn = db.model.get_connection()
# Equal local dequeue coordinates in separate sessions are legitimate.
starts = []
for i, session in enumerate(
(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
)
):
start = replace(
self.start(db, 2**53 + 1, 2**53 + 2),
session_id=session,
run_id=f"{i+1:08x}-1111-4111-8111-111111111111",
)
db.write_execution_start(start)
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
starts.append(start)
with db.recorded_history_snapshot():
p = db.list_history_window(1, limit=1)
q = db.list_history_window(1, limit=1, after=p["next_position"])
self.assertEqual(p["items"][0]["session_id"], starts[0].session_id)
self.assertEqual(q["items"][0]["session_id"], starts[1].session_id)
self.assertEqual(q["items"][0]["queue_item_id"], 2**53 + 1)
self.assertFalse(q["has_more"])
self.assertNotIn(
"rowid",
" ".join(
r[3]
for r in conn.execute(
"EXPLAIN QUERY PLAN SELECT run_id FROM execution_runs "
"ORDER BY observed_dequeue_at,session_id,queue_item_id,run_id"
)
),
)
def test_exact_scalar_collision_check_and_original_hint_invalidation(self):
db = self.database()
self.saved(db)
start = self.capture(db)
# A fake digest collision must never group different full scalars.
from utils.execution_contexts import content_digest
self.saved(db, "different", 2)
with patch(
"database.history_reads.content_digest",
return_value=content_digest("text", ("positive",)),
):
with db.recorded_history_snapshot():
self.assertEqual(db.list_history_window(2)["recorded_run_count"], 0)
self.saved(db, "positive", 3)
with db.recorded_history_snapshot():
context = db.get_history_run(start.run_id)["contexts"][0]
self.assertIsNone(context["positive_prompt_id"])
self.assertFalse(context["navigation"]["positive_available"])
with db.model.get_connection() as conn:
conn.execute("DELETE FROM prompts WHERE id IN (1,3)")
self.saved(db, "positive", 4)
with db.recorded_history_snapshot():
context = db.get_history_run(start.run_id)["contexts"][0]
self.assertIsNone(context["positive_prompt_id"])
self.assertEqual(context["reference_epoch"], start.storage_epoch)
def test_original_positive_hint_remains_but_negative_only_navigation_is_unavailable(
self,
):
db = self.database()
self.saved(db)
start = self.capture(db)
with db.recorded_history_snapshot():
original = db.get_history_run(start.run_id)["contexts"][0]
self.assertEqual(original["positive_prompt_id"], 1)
self.assertTrue(original["navigation"]["positive_available"])
self.saved(db, "other positive", 3)
with db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) "
"VALUES(3,1)"
)
with db.recorded_history_snapshot():
current = db.get_history_run(start.run_id)["contexts"][0]
self.assertIsNone(db.list_history_window(1))
self.assertEqual(current["positive_prompt_id"], 1)
self.assertEqual(current["reference_epoch"], original["reference_epoch"])
self.assertFalse(current["navigation"]["positive_available"])
self.assertEqual(current["positive"], original["positive"])
self.assertEqual(current["evidence"], original["evidence"])
def test_snapshot_ownership_cleanup_survives_connection_close(self):
from database.history_reads import _snapshots
db = self.database()
previous = _snapshots.get()
with self.assertRaises(sqlite3.ProgrammingError):
with db.recorded_history_snapshot():
db.close()
self.assertEqual(_snapshots.get(), previous)
with db.recorded_history_snapshot():
self.assertIsNone(db.get_history_run("missing"))
def assert_position_rejected_before_sql(self, db, read):
trace = []
conn = db.model.get_connection()
conn.set_trace_callback(trace.append)
try:
with self.assertRaises(Exception) as raised:
read()
self.assertIs(type(raised.exception), ValueError)
self.assertEqual(trace, [])
finally:
conn.set_trace_callback(None)
def test_malformed_output_fingerprint_is_not_capture_drift(self):
from database.history_reads import OutputPosition
db = self.database()
start = self.capture(db, output=outputs())
with db.recorded_history_snapshot():
self.assertEqual(
db.list_history_outputs(start.run_id)["retained_occurrence_count"], 1
)
self.assert_position_rejected_before_sql(
db,
lambda: db.list_history_outputs(
start.run_id, after=OutputPosition("not-a-sha256", ("9", 0))
),
)
def test_malformed_output_coordinate_does_not_reach_sql(self):
from database.history_reads import OutputPosition
db = self.database()
start = self.capture(db, output=outputs())
with db.recorded_history_snapshot():
fingerprint = db.list_history_outputs(start.run_id)["capture_fingerprint"]
self.assert_position_rejected_before_sql(
db,
lambda: db.list_history_outputs(
start.run_id, after=OutputPosition(fingerprint, ("9",))
),
)
def test_malformed_window_key_does_not_reach_sql(self):
from database.history_reads import WindowPosition
db = self.database()
self.saved(db)
self.capture(db)
with db.recorded_history_snapshot():
page = db.list_history_window(1)
self.assertEqual(page["recorded_run_count"], 1)
self.assert_position_rejected_before_sql(
db,
lambda: db.list_history_window(
1, after=WindowPosition(page["group"]["raw_text_id"], (1,), (1,))
),
)
def test_malformed_position_scalars_arity_and_bounds(self):
from database.history_reads import OutputPosition, WindowPosition
db = self.database()
self.saved(db)
start = self.capture(db, output=outputs())
with db.recorded_history_snapshot():
raw_id = db.list_history_window(1)["group"]["raw_text_id"]
fingerprint = db.list_history_outputs(start.run_id)["capture_fingerprint"]
key = (1, start.session_id, 0, start.run_id)
bad_digests = (None, 1, "a" * 63, "A" * 64, "g" * 64, "a" * 64 + "\0")
bad_keys = (None, (), (1,), (*key, 1), list(key))
for index in (0, 2):
for scalar in (None, True, 1.5, "1", -1, 2**63):
bad_keys += (key[:index] + (scalar,) + key[index + 1 :],)
for index in (1, 3):
for scalar in (
None,
1,
"x" * 36,
"AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA",
start.run_id + "\0",
):
bad_keys += (key[:index] + (scalar,) + key[index + 1 :],)
windows = [WindowPosition(digest, key, key) for digest in bad_digests]
for bad in bad_keys:
windows.extend(
(WindowPosition(raw_id, bad, key), WindowPosition(raw_id, key, bad))
)
windows.extend(
({}, (raw_id, key, key), OutputPosition(fingerprint, ("9", 0)))
)
bad_coordinates = (
None,
(),
("9",),
("9", 0, 1),
["9", 0],
(None, 0),
(9, 0),
("", 0),
("x" * 129, 0),
("😀" * 129, 0),
("nul\0node", 0),
("\ud800", 0),
("9", None),
("9", True),
("9", 1.5),
("9", "0"),
("9", -1),
("9", 256),
("9", 2**63),
)
output_positions = [
OutputPosition(digest, ("9", 0)) for digest in bad_digests
]
output_positions.extend(
OutputPosition(fingerprint, coordinate)
for coordinate in bad_coordinates
)
output_positions.extend(
({}, (fingerprint, ("9", 0)), WindowPosition(raw_id, key, key))
)
for position in windows:
with self.subTest(window=position):
self.assert_position_rejected_before_sql(
db, lambda: db.list_history_window(1, after=position)
)
for position in output_positions:
with self.subTest(output=position):
self.assert_position_rejected_before_sql(
db,
lambda: db.list_history_outputs(start.run_id, after=position),
)
def test_position_boundary_controls_preserve_binary_coordinates_and_large_keys(
self,
):
from database.history_reads import (
HistoryCaptureChanged,
OutputPosition,
WindowPosition,
)
db = self.database()
self.saved(db)
node = "😀" * 128
ref = replace(outputs().references[0], output_node_id=node, image_ordinal=255)
start = self.capture(
db, time=2**63 - 1, output=replace(outputs(), references=(ref,))
)
with db.recorded_history_snapshot():
page = db.list_history_window(1)
raw_id = page["group"]["raw_text_id"]
key = (2**63 - 1, start.session_id, 2**63 - 1, start.run_id)
older = db.list_history_window(1, after=WindowPosition(raw_id, key, key))
self.assertEqual(older["recorded_run_count"], 1)
self.assertEqual(older["items"][0]["run_id"], start.run_id)
output = db.list_history_outputs(start.run_id)
fingerprint = output["capture_fingerprint"]
end = db.list_history_outputs(
start.run_id, after=OutputPosition(fingerprint, (node, 255))
)
self.assertEqual(end["items"], [])
self.assertFalse(end["has_more"])
self.assertIsNone(end["next_position"])
with self.assertRaises(ValueError) as mismatch:
db.list_history_window(1, after=WindowPosition("0" * 64, key, key))
self.assertIs(type(mismatch.exception), ValueError)
with self.assertRaises(HistoryCaptureChanged):
db.list_history_outputs(
start.run_id, after=OutputPosition("0" * 64, (node, 0))
)
+42
View File
@@ -463,3 +463,45 @@ class TestInspectionResponse(unittest.TestCase):
)
self.assertEqual(result["metadata"]["raw"], read.metadata)
self.assertEqual(result["prompts"], PROMPTS)
def test_history_identity_shares_rooted_evidence_and_library_projection(self):
from utils.inspection_response import encode_history_inspection_response
read = inspection({"prompt": graph_text()})
identity = {
"occurrence": {
"run_id": "run",
"output_node_id": "node",
"image_ordinal": 2,
},
"reference": {"filename": "image.png"},
"history_epoch": "epoch",
"current_scope": "scope",
"location_key": "location",
"file_identity": "unverified",
}
body = json.loads(
encode_history_inspection_response(
current_output=identity,
file={"size": 7, "observation": "current-file"},
prompts=PROMPTS,
resolution={"state": "resolved"},
inspection=read,
library={"status": "linked", "tags": ["猫" * 12000]},
)
)
rooted = json.loads(
encode(
read,
file={"size": 7, "observation": "current-file"},
library={"status": "linked", "tags": ["猫" * 12000]},
)
)
self.assertEqual(body["current_output"], identity)
self.assertEqual(body.get("history_version"), 1)
self.assertEqual(body.get("history_epoch"), "epoch")
for key in ("file", "prompts", "metadata", "library"):
self.assertEqual(body[key], rooted[key])
self.assertEqual(body["library"]["status"], "limited")
self.assertNotIn("image", body)
self.assertNotIn("root_scope", body)
+2 -2
View File
@@ -28,10 +28,10 @@ test('execution script discovers only the new matrix and preserves all 78 stock
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
assert.match(executions, /Total: 42 tests in 2 files/);
assert.match(executions, /Total: 46 tests in 2 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 13);
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);