diff --git a/TESTING.md b/TESTING.md index 87cdc9a3..d055705d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -398,6 +398,11 @@ naming it, here and in a detailer's image ancestry; a latent that is not a link reaches its own sampler is `malformed`; running out of budget is `work-limit` or `depth-limit`, never single stage. Each sampler's walk runs once per resolution; selector branch runs share it and other candidates are charged its visit cost. +A UI workflow node whose `type` is a list or an object (read only for the overwritten +PromptManager check) makes the evidence `malformed` ("Prompt evidence is malformed"), +and a list or object `class_type` is never a preview output; before #286 both raised +and the inspect route answered 500 (`test_unhashable_*` in +`tests/test_api_prompt_inspector.py`). Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21` slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when @@ -1446,6 +1451,28 @@ the shared viewer with the full inspector on a record read entirely in this brow readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is uploaded, imported or stored. Only HTML and JS change, so no ComfyUI restart is needed. +Local roles (#249 PR L3, decision LI-1): the prompts and resolution of a local record are +the server inspector's for the same records. `web/js/workspace/local-resolver.mjs` (with +`local-resolver-support.mjs`, `local-resolver-stages.mjs`, `local-parameters.mjs` and +`py-json.mjs`) ports `inspect_prompt_evidence` with `INSPECTION_PROFILE`: execution +evidence, graph tracing, every bound, several sampler candidates with the +identical-branch rule (#270), preview-only stages, detailer stages (multistage) and A1111 +`parameters`. It reads the server's own node table, `web/js/inspection-semantics.json`, +which the page fetches once through the API client as it mounts, before any selection +(`local-semantics.mjs`; `/prompt_manager/js/inspection-semantics.json` is the one JSON +file the static route serves, by exact path). A table that cannot be fetched answers +"The inspector's node table is unavailable." as the server does. The legacy fill in +`projectInspectionPayload` then applies to a local record exactly where it applies to the +Images answer for the same bytes. Python is the oracle: +`tests/test_local_inspect_parity.py` `LocalResolverParityTests` compares the local +evidence byte for byte with `inspect_prompt_evidence` for every parity PNG and inspection +vector (prompts, resolution and facts), the golden and selection record sets +(`tests/local_resolver_cases.py`, `tests/local_resolver_selection_cases.py`), every work +bound and 1,200 seeded fuzz cases; `tests/fixtures/local-inspect/resolver-vectors.json` +holds the committed answers the Node unit test reads. The browser spec `chooser shows the +Images roles and resolution for every parity PNG` compares the rail with Images for all +25 committed parity PNGs. The static route change needs a ComfyUI restart. + Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity" under "Chronological Library history" for the known divergences): - PNG: at most 10,000 chunks; keyword 1–79 bytes; every chunk range checked against the @@ -1463,7 +1490,8 @@ under "Chronological Library history" for the known divergences): Privacy, asserted by `local inspection makes no content request or storage write` and `local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the -managed-host spec below: no request carries the file's bytes, name or text (fetch, XHR, +managed-host spec below: the only static file Inspect reads is its node table, once per +page and before any selection; no request carries the file's bytes, name or text (fetch, XHR, beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`, `sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its payload carries neither the `blob:` URL nor the filename; no console message names the @@ -1527,7 +1555,7 @@ mapped differences"): Modified with no Path row and no Show in folder, and ends with "Local file; no saved library association was checked." and Download original. There is no Prompt history action and no category or tag chips. -- **D-NEW-1** (Copy settings summary, LI-4; number to be assigned): the legacy metadata +- **D117** (Copy settings summary, LI-4): the legacy metadata page's COPY ALL, consolidated into the shared full inspector (the viewer's `#viewer-details`, so Images, the recorded history's current file and Inspect all have it). The concept has no such action. One plain button "Copy settings summary" follows @@ -1564,7 +1592,7 @@ Legacy floor (`web/metadata.html`), dispositions: - Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata". - Copy all summary and filename copy (LI-4): the filename is selectable text in the rail and Download original saves the file under its name. COPY ALL is the shared full - inspector's "Copy settings summary" (D-NEW-1), with the legacy text. + inspector's "Copy settings summary" (D117), with the legacy text. - Fully browser-local reading: kept and asserted (privacy above). - Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate backend issue; Inspect follows the server's readers, so the two never disagree. @@ -2157,6 +2185,12 @@ skip without ffmpeg). The remaining known divergences, checked against ffprobe 9 `unreadable`); ffprobe reads such files. - An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg only warns and reads it. +- Local roles (PR L3): a prompt graph nested deeper than CPython's `json.loads` + recursion limit (about 1,000 levels, depending on the Python version) is malformed on + the server and parsed locally. `\d` in the A1111 settings footer is any Unicode decimal + digit on both sides, but the browser's Unicode version can be newer than the server + Python's, so a footer whose step count uses a digit added after Unicode 15 can differ. + `str.lower()` and `toLowerCase()` differ only for characters no node class name uses. - Envelope JSON deeper than 1,000 levels is refused locally as Python's default recursion limit refuses it; the server's real limit depends on the interpreter and call stack. A duplicate key in an object enclosing a nest deeper than the running diff --git a/py/api/__init__.py b/py/api/__init__.py index b45b3d58..c9c19ffa 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -105,6 +105,9 @@ STATIC_MIME_TYPES = { ".html": "text/html", } _STATIC_SEPARATORS = re.compile(r"[\\/]+") +# Served by exact request path beside the extension allow-list: the browser's +# copy of the inspection node table (local Inspect resolves with it, #249). +STATIC_EXACT_FILES = {("js", "inspection-semantics.json"): "application/json"} def _resolve_static_file(root, filepath): @@ -475,15 +478,19 @@ class PromptManagerAPI( def _serve_static_file(self, subdir, filepath): """Serve ``web//``; 403 outside it, 404 when missing.""" root = os.path.join(_get_project_root(), "web", subdir) - file_path = _resolve_static_file(root, filepath) - if file_path is None: - return web.Response(text="Forbidden", status=403) + exact = STATIC_EXACT_FILES.get((subdir, filepath)) + if exact is not None: + file_path, content_type = os.path.join(root, filepath), exact + else: + file_path = _resolve_static_file(root, filepath) + if file_path is None: + return web.Response(text="Forbidden", status=403) + content_type = STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()] if not os.path.isfile(file_path): return web.Response(text="Not Found", status=404) - ext = os.path.splitext(file_path)[1].lower() with open(file_path, "rb") as f: content = f.read() - return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext]) + return web.Response(body=content, content_type=content_type) def _serve_html_page(self, filename, title, cache=True): """Serve ``web/``; 404 when missing, 500 when unreadable.""" diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 446a800f..1b40673a 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -237,7 +237,7 @@ const viewerStructure=details=>details.evaluate(node=>{ test('viewer details hold provenance, raw records and downloads',async({page,request})=>{ await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); - // The panel content without the cover, D-NEW-1's Copy settings summary after the facts, then the two disclosures, + // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, // then the pair send and history buttons (D20). expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button','details','details']}); diff --git a/tests/browser/inspect-concept.spec.mjs b/tests/browser/inspect-concept.spec.mjs index 62378356..48be585d 100644 --- a/tests/browser/inspect-concept.spec.mjs +++ b/tests/browser/inspect-concept.spec.mjs @@ -159,7 +159,7 @@ async function withProductionCopy(page) { } /** - * D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D-NEW-1 (LI-4): + * D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D117 (LI-4): * "Copy settings summary" follows the six facts. */ function mapViewerConcept(reference) { @@ -274,7 +274,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) { // The chooser names the chosen file after a real setInputFiles, as the concept's does. expect(production.initial.chosen).toEqual({ name: '', files: [] }); expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] }); - // D66 in the rail: no tag chips and no Prompt history; the title is the filename. D-NEW-1 follows the facts. + // D66 in the rail: no tag chips and no Prompt history; the title is the filename. D117 follows the facts. expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative', 'send negative', 'facts', 'settings summary', 'File & provenance', 'Workflow & raw metadata', 'send pair']); }); diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 643d4b75..41ae63a2 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -45,9 +45,9 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D-NEW-1); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must * equal production. `reference.viewerPlain` is the concept's own full details - * with its tag chips made plain (D20), D-NEW-1's summary button added and, under a coarse pointer, D34's targets, all at + * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. */ export function mapInspectorConcept(reference,{viewport}) { @@ -106,7 +106,7 @@ async function openProductionPanel(page,theme) { } /** - * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D-NEW-1's "Copy + * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", * without its action); returns an undo. */ @@ -351,7 +351,7 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke // The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS // mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5) -// its pair actions removed, D38's Show in folder removed and D-NEW-1's summary removed; each applied after an exact match. +// its pair actions removed, D38's Show in folder removed and D117's summary removed; each applied after an exact match. test('inspector comparison detects mutations',async({page})=>{ test.setTimeout(120_000); const viewport={width:1600,height:1100}; @@ -375,7 +375,7 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/); const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']}); expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/); - // D-NEW-1: the summary action after the facts is part of the mapped concept, so losing it is a difference. + // D117: the summary action after the facts is part of the mapped concept, so losing it is a difference. const withoutSummary=await viewerMutated({remove:['#viewer-details > div > button']}); expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index cc87cc25..48f81074 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -494,7 +494,7 @@ test('the inline current file names its prompts apart from the frozen run fields expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); }); -// LI-4 (D-NEW-1): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary. +// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary. test('the current file viewer copies its settings summary',async({page})=>{ await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});}); await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json(); diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs index 50ebeb51..b08a738d 100644 --- a/tests/browser/workspace-inspect.spec.mjs +++ b/tests/browser/workspace-inspect.spec.mjs @@ -207,6 +207,40 @@ test('chooser opens the shared full inspector without a second read', async ({ p expect(await page.evaluate(() => window.__slices)).toBe(0); }); +// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the +// prompts, their status lines and the facts Images shows for the same bytes in the output folder. +const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12', + 'f14', 'f15', 'f16', 'f17', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24']; +const roleSections = locator => locator.evaluate(node => [...node.querySelectorAll('.prompt-section')].map(item => item.textContent)); + +test('chooser shows the Images roles and resolution for every parity PNG', async ({ page, request }) => { + test.slow(); + for (const name of PARITY_PNGS) { + await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: `${name}.png` } })).toBeOK(); + } + const served = {}; + await page.goto(`${workspace}#/images`); + for (const name of PARITY_PNGS) { + await page.locator(`article[data-image-key="0:local-inspect/${name}.png"] .image-open`).click(); + await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`); + await expect(rail(page).locator('.panel-heading')).toBeVisible(); + await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0); + served[name] = { content: await railContent(rail(page)), roles: await roleSections(rail(page)) }; + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } + await openInspect(page); + for (const name of PARITY_PNGS) { + await choose(page, parityPng(`${name}.png`)); + await openViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`); + await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0); + expect({ content: await railContent(rail(page)), roles: await roleSections(rail(page)) }, name).toEqual(served[name]); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } +}); + test('Import → Inspect a local image lands here', async ({ page }) => { await page.goto(`${workspace}#/images`); await page.getByRole('button', { name: 'Import', exact: true }).click(); @@ -652,13 +686,19 @@ test('local roles send exactly', async ({ page, context }) => { test('local inspection makes no content request or storage write', async ({ page }) => { await instrument(page); - await openInspect(page); - // The shell's own reads (counts, Activity) settle before the baseline. - await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached(); - const baseline = (await urls(page)).writes.length; const requests = []; page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() })); page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null })); + const table = /\/prompt_manager\/js\/inspection-semantics\.json$/; + const tableRead = page.waitForResponse(response => table.test(response.url())); + await openInspect(page); + // The one static file Inspect reads, its node table, is fetched as the page mounts: before any selection. + expect((await tableRead).ok()).toBe(true); + // The shell's own reads (counts, Activity) settle before the baseline. + await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached(); + const baseline = (await urls(page)).writes.length; + const before = requests.length; + expect(requests.filter(entry => table.test(entry.url))).toHaveLength(1); for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) { await choose(page, file); await openViewer(page); @@ -666,7 +706,8 @@ test('local inspection makes no content request or storage write', async ({ page await page.keyboard.press('Escape'); await expect(viewer(page)).toHaveCount(0); } - const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:')); + const network = requests.slice(before).filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:')); + expect(requests.filter(entry => table.test(entry.url)), 'the table is read once per page').toHaveLength(1); expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]); expect(network.filter(entry => entry.method !== 'GET')).toEqual([]); expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]); @@ -686,7 +727,7 @@ test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ pag } }); -// LI-4 (D-NEW-1): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full +// LI-4 (D117): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full // inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows. /** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */ async function stubClipboard(page, { clipboard = true } = {}) { diff --git a/tests/js/support/local-inspect-oracle.mjs b/tests/js/support/local-inspect-oracle.mjs index 3c227d82..c85d1d12 100644 --- a/tests/js/support/local-inspect-oracle.mjs +++ b/tests/js/support/local-inspect-oracle.mjs @@ -42,11 +42,12 @@ const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'we /** The local evidence for one set of record texts. */ const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) }); -/** The six facts for one local read (no legacy fill here, as in the vectors' own harness). */ -function localFacts(read, file, classification, evidence) { - const payload = { ...buildLocalPayload({ read, file, classification }), prompts: evidence.prompts, resolution: evidence.resolution }; +/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */ +function localPayload(read, file, classification) { + const payload = buildLocalPayload({ read, file, classification, semantics }); const record = projectInspectionPayload(payload, { kind: 'local-file', key: 'local:1', path: file.name }, null, extractGenerationFacts); - return Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }])); + const facts = Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }])); + return { evidence: { prompts: payload.prompts, resolution: payload.resolution }, facts }; } const selectionRead = (selection) => selection.status === 'complete' ? @@ -63,8 +64,11 @@ for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) { const { classification, result } = await readLocalFile(blob, { pngMetadata }); const read = classification === null ? null : toInspectionRead(classification, result).read; if (classification === null) { output[name] = { unreadable: true }; continue; } - const answer = read === null ? { unreadable: true, evidence: unreadableEvidence() } : resolved(read.records); - output[name] = { ...answer, facts: localFacts(result, { name: path.basename(entry.path), size: blob.size }, classification, answer.evidence) }; + // The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records. + const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification); + const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence; + if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`); + output[name] = read === null ? { unreadable: true, ...local } : local; continue; } if (entry.records !== undefined) { diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index c0f87249..e4c8bf39 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -216,7 +216,7 @@ test('none and unavailable render no image chips', async () => { } }); -// LI-4 (D-NEW-1): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata +// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata // page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the // legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values. const METADATA_PAGE = fs.readFileSync(new URL('../../web/metadata.html', import.meta.url), 'utf8'); @@ -228,7 +228,7 @@ const legacyParams = record => ComfyMetadata.extractGenerationParams({prompt:rec workflow:record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null}); /** - * Each summary line against the legacy page's, allowing only the recorded differences (D-NEW-1): a number keeps the + * Each summary line against the legacy page's, allowing only the recorded differences (D117): a number keeps the * inspector's (the server's) spelling ("8.0" where the legacy page wrote 8); a fact the legacy parse left "Unknown" may * be known; and a negative the #249 ZeroOut guard zeroed reads "No negative prompt found" where the legacy parse read * the positive's text through the zero-out. diff --git a/tests/js/workspace-local-inspection.test.js b/tests/js/workspace-local-inspection.test.js index 07690552..b8e6e58f 100644 --- a/tests/js/workspace-local-inspection.test.js +++ b/tests/js/workspace-local-inspection.test.js @@ -20,6 +20,13 @@ const { extractGenerationFacts } = ComfyMetadata; // The page loads both as scripts; the legacy fill and the PNG reader find them on the global. globalThis.PngMetadata = PngMetadata; globalThis.ComfyMetadata = ComfyMetadata; +// The page's API client and the node table it serves (local-semantics.mjs fetches it once). +const SEMANTICS = fs.readFileSync(path.join(here, "..", "..", "web", "js", "inspection-semantics.json"), "utf8"); +globalThis.window = { ApiClient: { resolveUrl: (value) => value } }; +globalThis.fetch = async (url) => { + if (url !== "/prompt_manager/js/inspection-semantics.json") throw new Error(`unexpected request ${url}`); + return new Response(SEMANTICS, { status: 200 }); +}; const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => { if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; return {}; @@ -66,8 +73,12 @@ test("a PNG File becomes the server-shaped wire with local provenance", async () size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 }); assert.equal(wire.success, true); assert.equal(wire.inspection_version, 1); - assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } }); - assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null }); + // The roles the server's inspector resolves for the same records (local-resolver.mjs). + assert.deepEqual(wire.prompts, { + positive: { status: "resolved", text: "a red fox in snow, detailed fur", source: "graph-inferred" }, + negative: { status: "resolved", text: "blurry, lowres", source: "graph-inferred" }, + }); + assert.deepEqual(wire.resolution, { state: "resolved", sampler_id: "3", reason: "", reason_code: null, node: null }); assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null }); assert.equal(wire.metadata.status, "available"); assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW }); @@ -96,6 +107,33 @@ test("a PNG File becomes the server-shaped wire with local provenance", async () assert.equal(exported.file.source_label, "Selected local file"); }); +test("roles answer as the server's inspector: unreadable, unavailable table, ambiguous", async () => { + const png = { media: "image", kind: "png" }; + const unreadable = m.buildLocalPayload({ read: null, file: { name: "a.png" }, classification: png }); + assert.deepEqual(unreadable.resolution, { state: "unresolved", sampler_id: null, reason: "Media metadata could not be read", reason_code: null, node: null }); + assert.equal(unreadable.prompts.positive.message, "Metadata in this file could not be read."); + const records = { prompt: PROMPT }; + const read = { status: "complete", records, limited: [], width: 2, height: 3 }; + // Without a table (it could not be fetched) the roles stay open, with the server's own line. + const missing = m.buildLocalPayload({ read, file: { name: "a.png" }, classification: png }); + assert.equal(missing.resolution.reason, "Inspection node semantics are unavailable"); + assert.equal(missing.prompts.positive.message, "The inspector's node table is unavailable."); + const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics(); + const graph = JSON.parse(PROMPT); + graph["9"] = { ...graph["3"], inputs: { ...graph["3"].inputs, positive: ["8", 0] } }; + graph["8"] = { class_type: "CLIPTextEncode", inputs: { text: "a cat", clip: ["4", 1] } }; + const several = m.buildLocalPayload({ read: { ...read, records: { prompt: JSON.stringify(graph) } }, file: { name: "a.png" }, classification: png, semantics }); + assert.equal(several.resolution.reason, "Multiple sampler branches"); + assert.deepEqual(several.prompts.positive, { status: "ambiguous", text: null, source: null }); + // The legacy fill then applies exactly as it does to the server's answer for the same bytes in Images. + const handle = await handleFor(pngFile("grid.png", [tEXt("prompt", JSON.stringify(graph))])); + const local = m.localFileAdapter.toRecord(several, handle, extractGenerationFacts); + const asServed = { ...several, file: { ...several.file, observation: "current-file", source_label: "output" } }; + const served = projectInspectionPayload(asServed, { kind: "rooted-file", key: "0:grid.png", root_index: 0, path: "grid.png", root_scope: null }, null, extractGenerationFacts); + assert.deepEqual(local.prompts, served.prompts); + assert.deepEqual(local.generation, served.generation); +}); + test("file facts follow the server: extension like Path.suffix, null when unknown", async () => { const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]]; for (const [name, extension] of cases) { diff --git a/tests/js/workspace-local-semantics.test.js b/tests/js/workspace-local-semantics.test.js new file mode 100644 index 00000000..f0e657d1 --- /dev/null +++ b/tests/js/workspace-local-semantics.test.js @@ -0,0 +1,43 @@ +// web/js/workspace/local-semantics.mjs (#249 local Inspect, PR L3 Task 10): the node table local Inspect resolves +// with is the server's own web/js/inspection-semantics.json, fetched once through the API client before a file is +// chosen. A table that cannot be fetched or used answers as the server does when its table is unusable. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const TABLE = fs.readFileSync(new URL("../../web/js/inspection-semantics.json", import.meta.url), "utf8"); +let instance = 0; +/** A fresh module instance (its cache is per page) with `fetch` and the API client stubbed. */ +async function load(respond) { + const calls = []; + globalThis.window = { ApiClient: { resolveUrl: (path) => `/proxy${path}` } }; + globalThis.fetch = async (url, init) => { calls.push([url, init]); return respond(); }; + const module = await import(`../../web/js/workspace/local-semantics.mjs?instance=${++instance}`); + return { module, calls }; +} +test.after(() => { delete globalThis.window; delete globalThis.fetch; }); + +test("the table is fetched once through the API client and compiled", async () => { + const { module, calls } = await load(() => new Response(TABLE, { status: 200 })); + const [first, second] = await Promise.all([module.loadInspectionSemantics(), module.loadInspectionSemantics()]); + assert.equal(first, second); + assert.ok(first.samplers.has("KSampler")); + assert.equal(await module.loadInspectionSemantics(), first); + assert.deepEqual(calls, [["/proxy/prompt_manager/js/inspection-semantics.json", { credentials: "same-origin" }]]); +}); + +test("a failed or unusable table is null, and a later selection asks again", async () => { + for (const respond of [() => new Response("nope", { status: 404 }), () => new Response("{\"schema\": 2}", { status: 200 }), () => { throw new TypeError("offline"); }]) { + const { module, calls } = await load(respond); + assert.equal(await module.loadInspectionSemantics(), null); + assert.equal(await module.loadInspectionSemantics(), null); + assert.equal(calls.length, 2); + } +}); + +test("without the API client nothing is fetched", async () => { + const { module, calls } = await load(() => new Response(TABLE)); + delete globalThis.window; + assert.equal(await module.loadInspectionSemantics(), null); + assert.deepEqual(calls, []); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 1a0ebf93..91e1c3b9 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -521,7 +521,7 @@ test('copy fallback selects the text', async () => { assert.equal(note.className, 'small'); assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.'); assert.throws(() => openCopyFallback(42, { scope }), TypeError); - // LI-4 (D-NEW-1): the settings summary names its own dialog; the select-to-copy body is the same. + // LI-4 (D117): the settings summary names its own dialog; the select-to-copy body is the same. openCopyFallback('Seed: 7', { scope, title: 'Copy settings summary' }); assert.equal(byId(dom, 'modal-title').textContent, 'Copy settings summary'); assert.equal(byId(dom, 'copy-text').value, 'Seed: 7'); diff --git a/tests/test_api_core.py b/tests/test_api_core.py index 14f7c464..2829ccf9 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -477,6 +477,30 @@ class TestStaticRoutes(CoreRoutesTestCase): self.assertEqual(resp.status, 200) self.assertIn("application/javascript", resp.headers["Content-Type"]) + async def test_inspection_semantics_table_is_the_one_json_served(self): + """Local Inspect reads the node table the server resolves with (#249 PR L3). + + Only that exact file is served; every other JSON stays forbidden. + """ + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 200) + self.assertIn("application/json", resp.headers["Content-Type"]) + table = Path(__file__).resolve().parents[1] / "web" / "js" + self.assertEqual( + await resp.read(), (table / "inspection-semantics.json").read_bytes() + ) + for path in ( + "js/shared/toolkit/provenance.json", + "lib/viewerjs/provenance.json", + "lib/inspection-semantics.json", + "js/workspace/..%2Finspection-semantics.json", + "js/INSPECTION-SEMANTICS.json", + ): + resp = await self.client.request("GET", f"/prompt_manager/{path}") + self.assertIn(resp.status, (403, 404), path) + async def test_lib_served(self): web_dir = self._fake_root() (web_dir / "lib" / "x.css").write_text("body{}") diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index be804bf1..d9a3d922 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -329,12 +329,24 @@ "functions": 98, "statements": 98 }, + "workspace/local-parameters.mjs": { + "lines": 98, + "branches": 94, + "functions": 98, + "statements": 98 + }, "workspace/local-projection.mjs": { "lines": 97, "branches": 96, "functions": 98, "statements": 97 }, + "workspace/local-resolver-stages.mjs": { + "lines": 94, + "branches": 93, + "functions": 98, + "statements": 94 + }, "workspace/local-resolver-support.mjs": { "lines": 96, "branches": 80, @@ -342,10 +354,16 @@ "statements": 96 }, "workspace/local-resolver.mjs": { - "lines": 95, - "branches": 83, + "lines": 96, + "branches": 86, "functions": 98, - "statements": 95 + "statements": 96 + }, + "workspace/local-semantics.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 }, "workspace/local-session.mjs": { "lines": 98, @@ -391,7 +409,7 @@ }, "workspace/py-json.mjs": { "lines": 97, - "branches": 91, + "branches": 92, "functions": 98, "statements": 97 }, diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index fcb96378..59b2a3cb 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -27,13 +27,13 @@ const REVEAL_BUSY = 'The folder is already opening.'; const REVEAL_FAILED = 'The folder could not be opened. Try again.'; const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; -// D-NEW-1 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders. +// D117 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders. const SUMMARY_LINES = [['Checkpoint', 'model'], ['Positive Prompt', 'positive'], ['Negative Prompt', 'negative'], ['Steps', 'steps'], ['CFG Scale', 'cfg'], ['Sampler', 'sampler'], ['Seed', 'seed']]; const SUMMARY_MISSING = {positive:'No prompt found', negative:'No negative prompt found'}; /** - * The one-click settings summary (D-NEW-1): the legacy COPY ALL text for what this inspector shows. A confirmed prompt + * The one-click settings summary (D117): the legacy COPY ALL text for what this inspector shows. A confirmed prompt * and a known fact are copied verbatim; anything else, an explicitly empty prompt included, takes the legacy page's * placeholder, as its parse does (`hasValue` treats '' as absent). */ @@ -286,7 +286,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant if (!full && footer.childNodes.length) container.append(footer); } /** - * D-NEW-1 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt + * D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt * copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused. */ function summaryButton(record) { diff --git a/web/js/workspace/inspect.mjs b/web/js/workspace/inspect.mjs index cd6f2f7b..6e39f70e 100644 --- a/web/js/workspace/inspect.mjs +++ b/web/js/workspace/inspect.mjs @@ -7,6 +7,7 @@ import { pageHeading } from './heading.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs'; import { localFileAdapter } from './local-inspection.mjs'; +import { loadInspectionSemantics } from './local-semantics.mjs'; import { createMediaViewer } from './media-viewer.mjs'; import { showWorkspaceToast, TOAST_MS } from './toast.mjs'; @@ -84,6 +85,8 @@ export function mountInspect(main, { parentScope, api, sender }) { if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; } parentScope.signal.addEventListener('abort', dispose, { once: true }); scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + // The node table the local roles resolve with, read before any file is chosen (one request per page). + loadInspectionSemantics(); // The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview. const surface = element('section', 'surface'); diff --git a/web/js/workspace/local-inspection.mjs b/web/js/workspace/local-inspection.mjs index 3ccae323..81e7d9c7 100644 --- a/web/js/workspace/local-inspection.mjs +++ b/web/js/workspace/local-inspection.mjs @@ -6,12 +6,11 @@ import { readVideoTags, selectVideoCarrier } from './local-video.mjs'; import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs'; import { projectInspectionPayload } from './inspection-state.mjs'; +import { resolveLocalEvidence, unreadableEvidence } from './local-resolver.mjs'; +import { loadInspectionSemantics } from './local-semantics.mjs'; export const LOCAL_OBSERVATION = 'selected-local-file'; export const LOCAL_SOURCE_LABEL = 'Selected local file'; -// Interim roles until PR L3 ports the inspection resolver: every role is open, and -// the legacy fill in projectInspectionPayload shows what the legacy pages show. -const LOCAL_RESOLUTION_REASON = 'Resolved in this browser from the workflow.'; const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null }); const LOCAL_SCOPE = 'local'; const PREFIX_BYTES = 32; @@ -88,10 +87,16 @@ export function toInspectionRead(classification, result) { const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null; -/** The `inspection_version: 1` wire for one local read, before the response budget. */ -export function buildLocalPayload({ read, file, classification }) { +/** + * The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server + * inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers + * "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does + * for Images. + */ +export function buildLocalPayload({ read, file, classification, semantics = null }) { const classified = classificationOf(classification); const inspection = toInspectionRead(classified, read); + const evidence = inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics); const filename = sanitizeLocalFilename(file?.name); const size = Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : null; const modified = typeof file?.lastModified === 'number' && Number.isFinite(file.lastModified) && file.lastModified >= 0 ? file.lastModified / 1000 : null; @@ -101,8 +106,8 @@ export function buildLocalPayload({ read, file, classification }) { observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename), format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified, }, - prompts: { positive: { status: 'unresolved' }, negative: { status: 'unresolved' } }, - resolution: { state: 'unresolved', sampler_id: null, reason: LOCAL_RESOLUTION_REASON, reason_code: null, node: null }, + prompts: evidence.prompts, + resolution: evidence.resolution, metadata: projectMetadata(inspection.read), library: { ...LOCAL_LIBRARY, tags: [] }, }; @@ -176,7 +181,10 @@ export const localFileAdapter = Object.freeze({ // MetadataReadError, an unreadable record. Abort and programming errors still reject. if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error; } - return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification }), MAX_RESPONSE_BYTES, { signal }); + const semantics = await loadInspectionSemantics(); + signal?.throwIfAborted(); + const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }; + return fitLocalPayload(buildLocalPayload({ read: result, file, classification: secret.classification, semantics }), MAX_RESPONSE_BYTES, { signal }); }, toRecord(result, handle, extractFacts) { const secret = owned(handle); diff --git a/web/js/workspace/local-semantics.mjs b/web/js/workspace/local-semantics.mjs new file mode 100644 index 00000000..bfda05d7 --- /dev/null +++ b/web/js/workspace/local-semantics.mjs @@ -0,0 +1,27 @@ +// The node table local Inspect resolves with (#249 local Inspect, PR L3 Task 10): web/js/inspection-semantics.json, +// the very table the server's inspector reads, fetched once per page through the API client (the proxy-mount rule). +// The Inspect page asks for it as it mounts, before any file is chosen; it is the only static file Inspect fetches, +// and the request carries nothing about a selection. A table that cannot be fetched or used is null: the roles then +// answer as the server does when its table is unusable, and a later selection asks again. +import { compileSemantics } from './local-resolver.mjs'; + +export const SEMANTICS_PATH = '/prompt_manager/js/inspection-semantics.json'; +let pending = null; + +async function fetchSemantics() { + const client = globalThis.window?.ApiClient; + if (typeof client?.resolveUrl !== 'function' || typeof globalThis.fetch !== 'function') return null; + const response = await globalThis.fetch(client.resolveUrl(SEMANTICS_PATH), { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`Inspection node table request failed (${response.status})`); + return compileSemantics(await response.json()); +} + +/** The compiled table (one request per page), or null when it is unavailable. */ +export function loadInspectionSemantics() { + if (pending !== null) return pending; + const current = pending = fetchSemantics().catch(() => null).then(table => { + if (table === null && pending === current) pending = null; + return table; + }); + return current; +} diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 9d697255..85714f0f 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -282,7 +282,7 @@ export function openImportDialog({ scope, onInspect, onRescan, onMetadataScan = /** * "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. `title` names another copy's dialog - * (D-NEW-1 "Copy settings summary"). + * (D117 "Copy settings summary"). */ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy prompt' } = {}) { requireScope(scope); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 95af2388..75f98f5c 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -213,7 +213,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } where the toolkit's `nowrap` would push them past its edge. */ .ca-tools #viewer-details > button { white-space: normal; /* D20 */ } /* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */ -.ca-tools #viewer-details > div > button { white-space: normal; /* D-NEW-1 */ } +.ca-tools #viewer-details > div > button { white-space: normal; /* D117 */ } /* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */ .images-main { container: images-page / inline-size; } @container images-page (inline-size < 600px) {