test(history): verify chronological history across restore and host modes (#209)

This commit is contained in:
Vito Sansevero
2026-10-18 13:51:30 -07:00
parent a3010e151e
commit 3fb608bc21
13 changed files with 988 additions and 107 deletions
+109 -2
View File
@@ -226,8 +226,8 @@ loading a workflow or queueing generation.
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` runs 42 real execution cases
(21 each in Classic and Nodes 2.0, retaining all 26 prior execution cases),
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
alongside the existing 78 stock host cases and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
`PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`:
@@ -346,3 +346,110 @@ For a live output check after that manual restart:
Automated checks use only managed disposable hosts. They never restart or upgrade
the personal ComfyUI server.
## Chronological Library history (#209)
`#/library/{positive_id}` retains saved negative selection, the full selected
negative directly beneath its positive, acknowledged role/pair sending, and one
pair-filtered shared gallery. Recorded executions are a separate projection.
Selecting an execution, anchor, Raw/Effective mode, or reported occurrence does
not select a saved variant or refetch its gallery. A,A,B means three attempts,
two frozen contexts, and one exact raw-positive group. Different composition or
evidence can produce different frozen contexts despite equal-looking previews.
The versioned public reads are:
| Route | Meaning |
| --- | --- |
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
Run and output reads accept the returned `epoch`; current-file routes require
the returned epoch, current scope and occurrence coordinates. Run integers are
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
receipt pages default to 40 with a 1–100 limit. Cursors are opaque, authenticated,
scope-bound and capped at 4096 bytes. Run JSON has an 8 MiB UTF-8 limit and receipt
pages a 1 MiB limit; exceeding a limit fails explicitly instead of truncating
exact evidence. The unchanged capture limits remain 16 anchors/256 occurrences.
A cursor freezes its upper ordering boundary, not an application-wide snapshot:
each page has one SQLite read transaction and a count as of that read. Later
pages can observe completed capture, deletion or other current changes. Newer
runs require Refresh history. Unknown positive raw text cannot enter an exact
Library group but remains readable through its known run UUID. Unknown (`null`),
known empty (`""`) and whitespace are distinct. Complete-zero reported outputs
means the host reported none; missing capture means unknown, and partial or
conflicting capture remains incomplete. Receipts prove reported locations, not
historical bytes, newly generated files or context-output ownership. Cached
attempts can repeat exactly the same receipts.
Restore acceptance depends on merged #223: incompatible online storage
replacement must safely refuse without harming another owner's WAL/connection.
History fencing does not repair unsafe replacement. For accepted replacement,
the read transaction ends before a fresh-connection epoch check; the final
epoch/generation validation is the publication linearization point. Browser
generations also ignore late success/error callbacks after route/scope changes.
Old cursors, epoch and current-scope URLs fail with bounded recovery instructions.
Restored numeric IDs never repair an original navigation hint. Saved Library
details are revalidated separately, and a changed owner requires explicit reload.
Current media/thumbnail responses use an already-opened, validated canonical
file handle, bounded streaming and `Cache-Control: no-store`, including HEAD and
range behavior. Inspection is a current metadata observation with pre/post file
facts, canonical reauthorization and a fresh scope/epoch check; it is not pinned
historical bytes or an OS filesystem snapshot. A deterministic change during the
metadata read is rejected. A later request can legitimately observe replacement
bytes at the same path without changing the immutable receipt.
The focused acceptance command retains all four projects:
```sh
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
workspace-history.spec.mjs workspace-visual.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:executions
npm run --prefix tools/vendor test:comfyui
npm run --prefix tools/vendor test:compat
```
Use the pinned disposable prerequisites above; managed suites run serially.
The real execution helpers compare SQLite captures to these public routes for
stock SaveImage/PreviewImage batches, multiple nodes, metadata disabled, cached
headless repeats, partial failure and interruption. Additional cases verify the
actual PM A,A,B group, unknown-positive exclusion, and exact frozen Raw/Effective
pair sends through the acknowledged host bridge after Classic/Nodes 2.0 startup,
both switches and workflow reload, without queuing or loading a workflow on send.
All original 42 execution, 78 stock-host and six compatibility cases remain.
The browser fixture uses actual APIs/SQLite/files, fixed owned backup snapshots,
one-shot bounded publication barriers and fixed filesystem/configuration commands.
Reset and cleanup release barriers, restore seams and revoke commands. It never
attaches to a personal host. The canonical HTML and its six required assets are
served from a fixed test-only allowlist; the original concept index/app/style
bytes are hash-pinned and unmodified. At 1600×1100 and 390×844 in both themes,
with fixed locale/UTC/reduced motion and ready fonts, tests capture unmasked
concept/production PNG pairs and geometry, and pin actual branch padding,
surfaces, 2px role lines, 57px rhythm, staggering, colors, selected focus and native
dialog behavior. Controls and actions are checked independently of screenshots.
Keyboard focus/return, coarse targets, 200% layout, route return and persisted
page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. Broader Images card
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
and shared role-block spacing/padding/glyph polish remain outside #209. The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
a whole-application or full-gallery/Viewer pixel-parity claim.
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
source hashes before later managed commands clear their output directories.
Frontend baseline updates are additive through `coverage:update` only after
every previous metric floor and each new file's 80% line requirement pass; rerun
the coverage gate using the committed baseline. Python retains its 85% combined
statement/branch gate. Deploying the history Python changes requires a maintainer
ComfyUI restart; automated acceptance never restarts the personal server.
@@ -0,0 +1,159 @@
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',
} : {
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',
};
return page.evaluate(selectors => 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,
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 } };
})];
})), 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]);
}
+28
View File
@@ -18,9 +18,24 @@ 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:
default_prompt = "Describe the image"
@@ -84,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,
@@ -270,6 +297,7 @@ def create_app(root: Path) -> web.Application:
)
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)
+135
View File
@@ -16,6 +16,127 @@ test.beforeEach(async({page,request,browserName})=>{
});
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);
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]').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);
@@ -206,6 +327,20 @@ test('coarse-pointer markers expose 44px targets while retaining the small repea
} 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++;});
+91
View File
@@ -1,4 +1,7 @@
import { writeFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend } 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,94 @@ 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);
});
}
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']) {
+230 -2
View File
@@ -3,14 +3,19 @@
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 seed_workspace_visual
from tests.browser.workspace_visual_fixture import _write_art, seed_workspace_visual
from utils.execution_contexts import (
ContextBundle,
PromptSnapshot,
@@ -32,6 +37,172 @@ 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"}
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 == "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)
@@ -67,6 +238,7 @@ 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 (
@@ -259,12 +431,68 @@ def seed_workspace_history(
),
),
}
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()
return {
manifest = {
"positive_id": positive_id,
"detail_id": detail_id,
"aab": aab,
"runs": detail_runs,
"saved_pair": saved_pair,
"detail_image_id": current,
"canonical_id": canonical_id,
"canonical_pair": canonical_pair,
"canonical_run": canonical_run,
"canonical_positive": canonical_positive,
"canonical_negative": canonical_negative,
"merge_duplicate_id": merge_duplicate_id,
}
api._workspace_history_commands.reset(manifest)
return manifest
+77
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, readExecutionRuns } from './execution-fixture.mjs';
import { installHostHooks } from './host-fixture.mjs';
const sensitive = ['PM_SECRET_PROMPT_209', 'PM_SECRET_TOKEN_209', 'PM_SECRET_TRACE_209'];
const cached = value => ({ '1': { class_type: 'PMHistoryCached', inputs: { value } } });
@@ -194,9 +195,85 @@ async function capture(execution, id, outcome = 'succeeded') {
result = await readExecutionRuns(execution.databasePath, id);
return result.captures[run.run_id].state;
}, { timeout: 10000, message: 'Background writer persisted context finalization' }).not.toBe('not_captured');
const response = await execution.request.get(`/prompt_manager/history/runs/${run.run_id}`);
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
const detail = await response.json();
const fields = contexts => contexts.map(({ anchor_id, context_id, positive, negative, evidence }) => ({ anchor_id, context_id, positive, negative, evidence }));
expect(fields(detail.contexts)).toEqual(fields(result.captures[run.run_id].contexts));
expect(detail.run).toMatchObject({ run_id: run.run_id, outcome, queue_item_id: String(run.queue_item_id) });
return result.captures[run.run_id];
}
test.describe('public chronological history with the actual acknowledged host bridge', () => {
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
installHostHooks(test, ['PromptManagerPair']);
test('three actual attempts expose two contexts and send exact frozen roles through both renderer switches and reload', async ({ execution, page, context }, testInfo) => {
test.setTimeout(120000);
const positive = `public chronology ${testInfo.project.name} ${randomUUID()}\n猫`, attempts = [];
for (const negative of ['negative A', 'negative A', 'negative B']) {
const graph = recipeGraph({ text: positive, negative, prepend: ' pre ', append: ' post ' });
graph['60'] = { class_type: 'PMHistoryImages', inputs: { value: 128, batch: 2, latent: ['40', 0] } };
graph['70'] = { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: 'chronology/public' } };
graph['71'] = { class_type: 'PreviewImage', inputs: { images: ['60', 0] } };
const id = await submit(execution.request, graph);
await history(execution.request, id);const frozen = await capture(execution, id);
const persisted = await readExecutionRuns(execution.databasePath, id);attempts.push({ row: persisted.rows[0], context: frozen.contexts[0] });
}
const positiveId = attempts[0].context.positive_prompt_id;
const response = await execution.request.get(`/prompt_manager/library/${positiveId}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(3);expect(window.items.map(item => item.run_id)).toEqual(attempts.map(item => item.row.run_id));
expect(window.context_summaries).toHaveLength(2);expect(new Set(attempts.map(item => item.context.positive.raw))).toEqual(new Set([positive]));
expect(attempts.map(item => item.context.evidence.positive.effective.source)).toEqual(['runtime_return', 'host_cache_output', 'runtime_return']);
const workspace = await context.newPage();const errors = [];workspace.on('pageerror', error => errors.push(error.message));
try {
const destination = await page.evaluate(async () => {const { app } = await import('/scripts/app.js');const node = LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {const { app } = await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);await app.loadGraphData(app.graph.serialize());}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await workspace.goto(`/prompt_manager/workspace#/library/${positiveId}`);
const recorded = workspace.getByRole('region', { name: 'Recorded executions', exact: true });await expect(recorded.locator('[data-context-branch]')).toHaveCount(2);
await recorded.locator(`button[data-run-id="${attempts[0].row.run_id}"]`).click();
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
await expect(run.locator('[data-receipt]')).toHaveCount(4);
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
const frozen = attempts[0].context;
for (const mode of ['Raw', 'Effective']) {
await run.getByRole('button', { name: mode, exact: true }).click();
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
const applied = await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);return { positive: node.widgets.find(w => w.name === 'text').value, negative: node.widgets.find(w => w.name === 'negative_text').value, effects: window.chronologyEffects };}, destination);
expect(applied).toEqual({ positive: frozen.positive[mode.toLowerCase()], negative: frozen.negative[mode.toLowerCase()], effects: { queue: 0, load: 0 } });
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
} finally {await page.evaluate(async () => {const { app } = await import('/scripts/app.js');Object.assign(app, window.chronologyOriginals);});}
}
await workspace.keyboard.press('Escape');await expect(recorded).toBeVisible();
}
const versions = await (await execution.request.get('/system_stats')).json();await testInfo.attach('chronology-runtime.json', { body: JSON.stringify(versions.system, null, 2), contentType: 'application/json' });
expect(errors).toEqual([]);
} finally {await workspace.close();}
});
});
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
const captures = [];
const positiveSnapshotIds = [];
+18
View File
@@ -33,6 +33,24 @@ async function persisted(execution, id, outcomes) {
}, { timeout: 15000, message: 'Exact attempts and final output evidence reach SQLite' }).toEqual({
outcomes, states: outcomes.map(() => 'complete'),
});
for (const row of result.rows) {
const detailResponse = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}`);
expect(detailResponse.ok()).toBe(true);
const detail = await detailResponse.json();
expect(detail.run).toMatchObject({ run_id: row.run_id, outcome: row.outcome, queue_item_id: String(row.queue_item_id) });
const response = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}/outputs?epoch=${detail.history_epoch}`);
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
const publicOutputs = await response.json(), captured = result.outputs[row.run_id];
expect(publicOutputs.capture).toMatchObject({ state: captured.state, reason: captured.reason, source: captured.source });
expect(publicOutputs.retained_occurrence_count).toBe(captured.references.length);
expect(publicOutputs.items.map(item => ({ ...item.occurrence, ...item.reference })).map(({ run_id, ...reference }) => reference)).toEqual(
captured.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key }) =>
({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key })));
for (const item of publicOutputs.items) {
expect(item).toMatchObject({ file_identity: 'unverified', newly_generated: 'unknown', context_membership: 'unknown' });
expect(item.current_candidate.evidence_class).toBe('locator_candidate');
}
}
return result;
}
+2 -2
View File
@@ -28,10 +28,10 @@ test('execution script discovers only the new matrix and preserves all 78 stock
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
assert.match(executions, /Total: 42 tests in 2 files/);
assert.match(executions, /Total: 46 tests in 2 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 13);
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
+118 -94
View File
@@ -7,7 +7,13 @@
},
"api-client.js": {
"lines": 98,
"branches": 94,
"branches": 95,
"functions": 98,
"statements": 98
},
"classic-node.js": {
"lines": 98,
"branches": 79,
"functions": 98,
"statements": 98
},
@@ -23,6 +29,12 @@
"functions": 98,
"statements": 98
},
"focused-node.js": {
"lines": 97,
"branches": 87,
"functions": 90,
"statements": 97
},
"gallery-settings.js": {
"lines": 98,
"branches": 90,
@@ -53,12 +65,36 @@
"functions": 98,
"statements": 98
},
"metadata-autocomplete.js": {
"lines": 98,
"branches": 82,
"functions": 85,
"statements": 98
},
"png-metadata.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"prompt-bridge-adapter.js": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"prompt-bridge-host.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"prompt-library.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"prompt-list-sort.js": {
"lines": 98,
"branches": 98,
@@ -66,10 +102,28 @@
"statements": 98
},
"scan-progress.js": {
"lines": 97,
"branches": 91,
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 97
"statements": 98
},
"shared/toolkit/core.js": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"shared/toolkit/prompt-bridge.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"shared/toolkit/suggestions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"sse-stream.js": {
"lines": 98,
@@ -101,60 +155,36 @@
"functions": 98,
"statements": 98
},
"classic-node.js": {
"lines": 98,
"branches": 79,
"functions": 98,
"statements": 98
},
"shared/toolkit/core.js": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"focused-node.js": {
"workspace/file-inspector.mjs": {
"lines": 95,
"branches": 73,
"functions": 87,
"branches": 92,
"functions": 88,
"statements": 95
},
"prompt-library.js": {
"workspace/history-media.mjs": {
"lines": 98,
"branches": 86,
"functions": 98,
"statements": 98
},
"metadata-autocomplete.js": {
"lines": 98,
"branches": 81,
"functions": 85,
"statements": 98
"workspace/history-state.mjs": {
"lines": 97,
"branches": 91,
"functions": 96,
"statements": 97
},
"shared/toolkit/suggestions.js": {
"workspace/history.mjs": {
"lines": 96,
"branches": 87,
"functions": 98,
"statements": 96
},
"workspace/icons.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/index.mjs": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"workspace/state.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/theme.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/image-layout.mjs": {
"lines": 98,
"branches": 98,
@@ -169,40 +199,28 @@
},
"workspace/images.mjs": {
"lines": 97,
"branches": 87,
"branches": 88,
"functions": 98,
"statements": 97
},
"prompt-bridge-adapter.js": {
"workspace/index.mjs": {
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 98
},
"workspace/inspection-state.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"prompt-bridge-host.js": {
"workspace/library-layout.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"shared/toolkit/prompt-bridge.js": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/prompt-inspector.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/prompt-sender.mjs": {
"lines": 98,
"branches": 89,
"functions": 98,
"statements": 98
},
"workspace/library-state.mjs": {
"lines": 98,
"branches": 94,
@@ -210,35 +228,11 @@
"statements": 98
},
"workspace/library.mjs": {
"lines": 94,
"branches": 80,
"functions": 93,
"statements": 94
},
"workspace/icons.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/library-layout.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/file-inspector.mjs": {
"lines": 95,
"branches": 92,
"functions": 88,
"branches": 84,
"functions": 94,
"statements": 95
},
"workspace/inspection-state.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/media-viewer.mjs": {
"lines": 98,
"branches": 93,
@@ -246,10 +240,40 @@
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 88,
"branches": 88,
"lines": 89,
"branches": 89,
"functions": 98,
"statements": 88
"statements": 89
},
"workspace/prompt-inspector.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/prompt-sender.mjs": {
"lines": 98,
"branches": 91,
"functions": 98,
"statements": 98
},
"workspace/recorded-run.mjs": {
"lines": 95,
"branches": 83,
"functions": 71,
"statements": 95
},
"workspace/state.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/theme.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/viewer-state.mjs": {
"lines": 98,
+1 -1
View File
@@ -43,7 +43,7 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
const timeline = el('div', undefined, 'history-timeline');timeline.setAttribute('aria-label', 'Chronological branch timeline');
const canvas = el('div', undefined, 'history-canvas');
const edge = el('span', undefined, 'history-earlier-edge');edge.setAttribute('aria-hidden', 'true');
const positive = el('div', 'Positive raw text · unchanged', 'history-positive-lane');positive.setAttribute('aria-hidden', 'true');
const positive = el('div', undefined, 'history-positive-lane');positive.append(el('span', 'Positive raw text · unchanged'));positive.setAttribute('aria-hidden', 'true');
const branches = el('div', undefined, 'history-context-branches');branches.setAttribute('aria-hidden', 'true');
const positions = el('ol', undefined, 'history-run-list');positions.setAttribute('aria-label', 'Recorded run positions');
canvas.append(edge, positive, branches, positions);timeline.append(canvas);
+16 -4
View File
@@ -53,17 +53,27 @@ main.library-main { --library-negative: #ff8c98; }
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
.library-main .library-branches { overflow-x: auto; position: relative; }
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: var(--ca-field); text-align: center; font-size: 16px; line-height: 25px; }
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
.library-main .library-variants .library-branches { margin-block: 0; }
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: var(--ca-field); }
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
.history-scope summary { cursor: pointer; }
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
.history-canvas { position: relative; margin: 18px 20px; }
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; }
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
@@ -75,7 +85,7 @@ main.library-main { --library-negative: #ff8c98; }
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); }
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-line); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-border); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
.recorded-run-dialog::backdrop { background: #0009; }
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.recorded-run-header h2 { margin: 0; font-size: 20px; }
@@ -96,10 +106,12 @@ main.library-main { --library-negative: #ff8c98; }
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
@media (max-width: 700px) {
.library-main .library-surface { padding: 22px; }
.recorded-run-dialog { padding: 16px; }
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
}
@media (pointer: coarse) {
.recorded-history :where(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: var(--ca-field); background: var(--library-negative); }
+4 -2
View File
@@ -253,7 +253,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const all=createButton('All images for this positive');
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
const branches=el('div',undefined,'library-branches');branches.append(el('p','Positive prompt · unchanged','library-positive-lane'));
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
const choices=[];
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
@@ -279,7 +279,9 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
};
updateBranches(state);
if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted'));
const note=el('p','Saved combinations, not an execution timeline.','muted');variants.append(branches,variantCount,variantError,more,retry,note,all);
const note=el('p','Saved combinations, not an execution timeline.','muted');
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
variants.append(branches,variantError,more,retry,variantFooter);
const context=el('p','','library-variant-context');context.setAttribute('role','status');
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;