diff --git a/TESTING.md b/TESTING.md index 0e356ad..890b673 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs new file mode 100644 index 0000000..a4d16ee --- /dev/null +++ b/tests/browser/history-canonical-comparison.mjs @@ -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]); +} diff --git a/tests/browser/server.py b/tests/browser/server.py index 84efbbc..df17e50 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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) diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index 5ed5107..38fe62d 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -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++;}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 75d2f1a..357c4c2 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -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']) { diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py index a863d9e..6fddd8c 100644 --- a/tests/browser/workspace_history_fixture.py +++ b/tests/browser/workspace_history_fixture.py @@ -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 diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs index 6cf84ac..cb85c24 100644 --- a/tests/comfyui/execution-history.spec.mjs +++ b/tests/comfyui/execution-history.spec.mjs @@ -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 = []; diff --git a/tests/comfyui/execution-outputs.spec.mjs b/tests/comfyui/execution-outputs.spec.mjs index ad98a57..22529f3 100644 --- a/tests/comfyui/execution-outputs.spec.mjs +++ b/tests/comfyui/execution-outputs.spec.mjs @@ -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; } diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 8e87063..0c97b09 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -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/); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ceeb6ab..ce0a193 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs index 42e0e47..1148282 100644 --- a/web/js/workspace/history.mjs +++ b/web/js/workspace/history.mjs @@ -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); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 0dc126f..fa3e260 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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); } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 33cc3ee..02ed149 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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;