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:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user