Merge remote-tracking branch 'origin/main' into feat/249-needs-review-backend
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { captureCanonicalState } from './history-canonical-comparison.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
import { parityPng } from './local-inspect-files.mjs';
|
||||
|
||||
// PR L2 Task 7 of the local-Inspect plan (#249): the native "Inspect a file" page against the immutable concept's
|
||||
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
|
||||
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
|
||||
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
|
||||
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
const FILE = 'f02.png';
|
||||
/** D60 and D61, the maintainer's LI-3 copy, keyed by the concept's own copy. */
|
||||
const COPY = {
|
||||
action: ['Open sample metadata', 'Open file metadata'],
|
||||
intro: ['Keep the existing local-file inspection workflow. Only add the file to your library when you choose to import it.',
|
||||
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
|
||||
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
|
||||
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
|
||||
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
|
||||
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
|
||||
'Local preview only. No file uploaded; metadata is available above.'],
|
||||
};
|
||||
/** D62: the concept's image list plus the videos the local readers support. */
|
||||
const ACCEPT = ['image/png,image/jpeg,image/webp', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv'];
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
/** Runs in the page: the Inspect surface below the heading, and the heading's actions. Self-contained. */
|
||||
function measureInspect(scope) {
|
||||
const main = document.querySelector(scope);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const font = node => css(node, 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color');
|
||||
const origin = main.querySelector(':scope > section.surface');
|
||||
const box = node => {
|
||||
if (!node || !origin) return null;
|
||||
const a = node.getBoundingClientRect(), b = origin.getBoundingClientRect();
|
||||
return { left: Math.round(a.left - b.left), top: Math.round(a.top - b.top), width: Math.round(a.width), height: Math.round(a.height) };
|
||||
};
|
||||
const text = node => node ? node.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
const heading = main.querySelector(':scope > .heading');
|
||||
const surface = origin;
|
||||
const drop = surface?.querySelector(':scope > .file-drop');
|
||||
const input = drop?.querySelector('input#local-file');
|
||||
const preview = surface?.querySelector(':scope > #local-preview');
|
||||
const media = preview?.querySelector('.local-file-preview');
|
||||
return {
|
||||
heading: {
|
||||
title: text(heading?.querySelector('h1')),
|
||||
description: text(heading?.querySelector('p')),
|
||||
actions: [...(heading?.querySelectorAll('.heading-actions > *') ?? [])].map(node => ({ name: name(node), text: text(node),
|
||||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'), box: box(node) })),
|
||||
},
|
||||
surface: surface && {
|
||||
children: [...surface.children].map(name), box: box(surface),
|
||||
...css(surface, 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle',
|
||||
'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'),
|
||||
h2: { text: text(surface.querySelector(':scope > h2')), ...font(surface.querySelector(':scope > h2')), box: box(surface.querySelector(':scope > h2')) },
|
||||
p: { text: text(surface.querySelector(':scope > p')), ...font(surface.querySelector(':scope > p')),
|
||||
...css(surface.querySelector(':scope > p'), 'marginTop'), box: box(surface.querySelector(':scope > p')) },
|
||||
},
|
||||
drop: drop && {
|
||||
children: [...drop.children].map(name), box: box(drop),
|
||||
...css(drop, 'display', 'placeItems', 'textAlign', 'minHeight', 'rowGap', 'columnGap', 'marginTop', 'paddingTop',
|
||||
'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle', 'borderTopColor',
|
||||
'borderBottomStyle', 'borderLeftStyle', 'borderRightStyle', 'borderTopLeftRadius', 'backgroundColor'),
|
||||
icon: { box: box(drop.querySelector(':scope > svg.icon')), viewBox: drop.querySelector(':scope > svg.icon')?.getAttribute('viewBox'),
|
||||
d: drop.querySelector(':scope > svg.icon path')?.getAttribute('d') ?? null,
|
||||
...css(drop.querySelector(':scope > svg.icon'), 'color', 'strokeWidth') },
|
||||
label: { text: text(drop.querySelector(':scope > label')), for: drop.querySelector(':scope > label')?.htmlFor ?? null,
|
||||
...font(drop.querySelector(':scope > label')), box: box(drop.querySelector(':scope > label')) },
|
||||
input: input && { type: input.type, accept: input.accept, multiple: input.multiple, ...css(input, 'maxWidth', 'fontSize', 'color'),
|
||||
box: box(input) },
|
||||
note: { text: text(drop.querySelector(':scope > span.small')), ...font(drop.querySelector(':scope > span.small')),
|
||||
box: box(drop.querySelector(':scope > span.small')) },
|
||||
},
|
||||
preview: preview && {
|
||||
children: [...preview.children].map(name), box: box(preview),
|
||||
media: media && { tag: media.localName, alt: media.getAttribute('alt'), blob: media.src.startsWith('blob:'),
|
||||
...css(media, 'width', 'maxHeight', 'objectFit', 'marginTop', 'display'), box: box(media) },
|
||||
},
|
||||
// The chooser's displayed name: a file input shows the last path segment of its value.
|
||||
chosen: input ? { name: input.value.split(/[\\/]/).pop(), files: [...input.files].map(file => file.name) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: the open viewer's dialog box, its toolbar controls in order and its rail's sections in order. */
|
||||
function measureViewer() {
|
||||
const dialog = document.querySelector('dialog#viewer-dialog[open]');
|
||||
if (!dialog) return null;
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const rect = node => { const b = node.getBoundingClientRect(); return { left: Math.round(b.left), top: Math.round(b.top), width: Math.round(b.width), height: Math.round(b.height) }; };
|
||||
const text = node => node.textContent.replace(/\s+/g, ' ').trim();
|
||||
const rail = dialog.querySelector('#viewer-details');
|
||||
// The rail's sections in document order, whatever wraps them.
|
||||
const SECTIONS = [
|
||||
['.panel-heading', () => 'heading'],
|
||||
['.panel-heading + p.small', () => 'filename'],
|
||||
['.tags', node => node.querySelector('button.chip') ? 'tag chips' : 'badges'],
|
||||
['section.prompt-section.positive', () => 'positive'],
|
||||
['section.prompt-section.negative', () => 'negative'],
|
||||
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
|
||||
['details > summary', node => text(node)],
|
||||
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
|
||||
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null],
|
||||
];
|
||||
const sections = [...rail.querySelectorAll('*')].map(node => {
|
||||
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
return {
|
||||
dialog: rect(dialog),
|
||||
// The layout below the header; the header's height follows the title (D66) and the rail's height its content.
|
||||
layout: (() => {
|
||||
const header = dialog.querySelector('.dialog-header').getBoundingClientRect().bottom;
|
||||
const left = rect(dialog.querySelector('.viewer-left')), side = rect(rail);
|
||||
return { left: { ...left, top: Math.round(left.top - header) }, rail: { left: side.left, width: side.width },
|
||||
...css(dialog.querySelector('.viewer-layout'), 'display', 'gridTemplateColumns') };
|
||||
})(),
|
||||
title: text(dialog.querySelector('#viewer-title')),
|
||||
subtitle: text(dialog.querySelector('.dialog-header p')),
|
||||
toolbar: [...dialog.querySelectorAll('#viewer-toolbar > button, #viewer-toolbar > a')].map(text),
|
||||
railTitle: text(rail.querySelector('.panel-heading h2')),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D60–D62 applied to the open concept page before it is measured: production's action label, copy and accept list,
|
||||
* so the browser lays out what follows them. The concept file itself is never touched.
|
||||
*/
|
||||
async function withProductionCopy(page) {
|
||||
await page.evaluate(({ COPY, ACCEPT }) => {
|
||||
const main = document.querySelector('#main');
|
||||
const swap = (node, [from, to]) => { if (node.textContent !== from) throw new Error(`concept copy changed: ${node.textContent}`); node.textContent = to; };
|
||||
swap(main.querySelector('.heading-actions > button'), COPY.action);
|
||||
swap(main.querySelector('section.surface > p'), COPY.intro);
|
||||
swap(main.querySelector('.file-drop > label'), COPY.chooser);
|
||||
swap(main.querySelector('.file-drop > span.small'), COPY.note);
|
||||
const input = main.querySelector('#local-file');
|
||||
if (input.accept !== ACCEPT[0]) throw new Error(`concept accept changed: ${input.accept}`);
|
||||
input.accept = ACCEPT[1];
|
||||
}, { COPY, ACCEPT });
|
||||
}
|
||||
|
||||
/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */
|
||||
function mapViewerConcept(reference) {
|
||||
return {
|
||||
...reference,
|
||||
title: FILE,
|
||||
railTitle: FILE,
|
||||
sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'),
|
||||
};
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle = page => page.evaluate(async () => {
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})));
|
||||
});
|
||||
|
||||
async function conceptStates(page, theme, testInfo, tag) {
|
||||
await openConcept(page, 'metadata', theme);
|
||||
await page.mouse.move(0, 0);
|
||||
await withProductionCopy(page);
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#main');
|
||||
await captureCanonicalState(page, testInfo, `concept-initial-${tag}`);
|
||||
await page.locator('#main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#toast')).toHaveText(COPY.toast[0]);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#main');
|
||||
await captureCanonicalState(page, testInfo, `concept-selected-${tag}`);
|
||||
await page.locator('#main .heading-actions > button').click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `concept-viewer-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
async function productionStates(page, theme, testInfo, tag) {
|
||||
await openProduction(page, 'inspect', theme);
|
||||
await page.mouse.move(0, 0);
|
||||
const action = page.locator('#workspace-main .heading-actions').getByRole('button', { name: COPY.action[1], exact: true });
|
||||
// D60: unavailable, and described by the drop note, until a classified file is chosen.
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(action).toHaveAttribute('aria-describedby', 'local-file-note');
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
|
||||
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
await action.click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
/** The concept's fixed CSS values (style.css 709–722, 1180–1194, 1543–1545, 1559–1564), restated as the plan pins them. */
|
||||
function expectPinnedValues(state) {
|
||||
expect(state.surface).toMatchObject({ paddingTop: '22px', paddingLeft: '22px', borderTopLeftRadius: '7px', borderTopStyle: 'solid' });
|
||||
expect(state.surface.h2).toMatchObject({ fontSize: '17px', fontWeight: '600' });
|
||||
expect(state.surface.p).toMatchObject({ fontSize: '12px' });
|
||||
expect(state.drop).toMatchObject({ display: 'grid', textAlign: 'center', minHeight: '170px', rowGap: '12px', columnGap: '12px',
|
||||
marginTop: '20px', paddingTop: '22px', paddingLeft: '22px', borderTopStyle: 'dashed', borderBottomStyle: 'dashed',
|
||||
borderLeftStyle: 'dashed', borderRightStyle: 'dashed', borderTopWidth: '1px', borderTopLeftRadius: '7px' });
|
||||
expect(state.drop.input).toMatchObject({ maxWidth: '100%', fontSize: '12px' });
|
||||
}
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`inspect surface matches the concept ${width}x${height} ${theme}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await seedConcept(request);
|
||||
const tag = `${theme}-${width}`;
|
||||
const concept = await conceptStates(page, theme, testInfo, tag);
|
||||
const production = await productionStates(page, theme, testInfo, tag);
|
||||
const mappedViewer = mapViewerConcept(concept.viewer);
|
||||
const diff = {
|
||||
initial: diffInventory(concept.initial, production.initial),
|
||||
selected: diffInventory(concept.selected, production.selected),
|
||||
viewer: diffInventory(mappedViewer, production.viewer),
|
||||
};
|
||||
const path = testInfo.outputPath('inspect-geometry.json');
|
||||
await writeFile(path, JSON.stringify({ concept, production, diff }, null, 2));
|
||||
await testInfo.attach('inspect-geometry', { path, contentType: 'application/json' });
|
||||
|
||||
// The concept is what this comparison assumes: its structure, its own copy (swapped above) and its chooser.
|
||||
expect(concept.initial.surface.children).toEqual(['h2', 'p', 'div.file-drop.spaced-20', 'div']);
|
||||
expect(concept.initial.drop.children).toEqual(['svg.icon', 'label', 'input', 'span.small']);
|
||||
expect(concept.selected.preview.children).toEqual(['img.local-file-preview']);
|
||||
expect(concept.selected.chosen).toEqual({ name: FILE, files: [FILE] });
|
||||
expect(concept.viewer.toolbar).toEqual(['← Previous', '−', '+', 'Fit', 'Rotate', 'Flip', 'Play', 'Download', 'Next →']);
|
||||
|
||||
expect(diff).toEqual({ initial: [], selected: [], viewer: [] });
|
||||
for (const state of [production.initial, production.selected]) expectPinnedValues(state);
|
||||
expect(production.selected.preview.media).toMatchObject({ tag: 'img', alt: 'Local file preview', blob: true,
|
||||
maxHeight: '400px', objectFit: 'contain', marginTop: '20px' });
|
||||
// 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.
|
||||
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
|
||||
'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
|
||||
});
|
||||
}
|
||||
|
||||
test('mutation is noticed', async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
await openConcept(page, 'metadata', 'dark');
|
||||
await withProductionCopy(page);
|
||||
const concept = await page.evaluate(measureInspect, '#main');
|
||||
await openProduction(page, 'inspect', 'dark');
|
||||
await page.mouse.move(0, 0);
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
|
||||
|
||||
// A solid border on the drop area is noticed on every side and nowhere else.
|
||||
const undoCss = await mutate(page, { css: '#workspace-main .file-drop { border-style: solid; }' });
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
|
||||
.toEqual(['drop.borderTopStyle', 'drop.borderBottomStyle', 'drop.borderLeftStyle', 'drop.borderRightStyle']);
|
||||
await undoCss();
|
||||
// A renamed label is noticed (its text; its box only if the new words lay out differently).
|
||||
const label = page.locator('#workspace-main .file-drop > label');
|
||||
await label.evaluate(node => { node.dataset.original = node.textContent; node.textContent = 'Choose a file'; });
|
||||
const renamed = diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path);
|
||||
expect(renamed).toContain('drop.label.text');
|
||||
expect(renamed.every(path => path.startsWith('drop.label.'))).toBe(true);
|
||||
await label.evaluate(node => { node.textContent = node.dataset.original; delete node.dataset.original; });
|
||||
// A removed note is noticed by its path.
|
||||
const undoDom = await mutate(page, { remove: ['#workspace-main .file-drop > span.small'] });
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
|
||||
.toEqual(expect.arrayContaining(['drop.children.3', 'drop.note.text']));
|
||||
await undoDom();
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
|
||||
await captureCanonicalState(page, testInfo, 'production-after-mutations');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Files for the Inspect page workflows, as Playwright `setInputFiles` payloads ({name, mimeType, buffer}) or as the
|
||||
// bytes a page builds a File or DataTransfer from. Built from the committed fixtures and the byte-exact PNG builders
|
||||
// the node tests use (tests/js/helpers/png-bytes.mjs), so the browser reads exactly what the unit tests pin.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { raw, ihdr, idat, iend, tEXt } from '../js/helpers/png-bytes.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(here, '..', 'fixtures');
|
||||
const read = (...parts) => fs.readFileSync(path.join(FIXTURES, ...parts));
|
||||
const file = (name, mimeType, buffer) => ({ name, mimeType, buffer: Buffer.from(buffer) });
|
||||
|
||||
/** One committed parity PNG (tests/fixtures/inspection_parity), e.g. f02.png. */
|
||||
export const parityPng = (name, as = name) => file(as, 'image/png', read('inspection_parity', name));
|
||||
/** One committed local-inspect fixture (tests/fixtures/local-inspect). */
|
||||
export const localFixture = (name, mimeType, as = name) => file(as, mimeType, read('local-inspect', name));
|
||||
/** The committed CC0 VP8 clip with no generation metadata. */
|
||||
export const tinyWebm = () => file('tiny.webm', 'video/webm', read('tiny.webm'));
|
||||
|
||||
/** One decodable 2×2 opaque pixel row set, so a synthetic PNG previews. */
|
||||
function pixels(width = 2, height = 2) {
|
||||
const row = [0, ...Array.from({ length: width }, () => [40, 120, 200, 255]).flat()];
|
||||
return zlib.deflateSync(Buffer.from(Array.from({ length: height }, () => row).flat()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A previewable PNG whose positive spans lines and whose negative is a ConditioningZeroOut: the legacy fill shows the
|
||||
* positive exactly and the ZeroOut guard makes the negative explicitly empty.
|
||||
*/
|
||||
export const POSITIVE_LINES = 'a lighthouse at dusk\nsoft rain, long exposure';
|
||||
export function sendablePng(name = 'send-me.png') {
|
||||
const prompt = JSON.stringify({
|
||||
3: { class_type: 'KSampler', inputs: { seed: 11, steps: 12, cfg: 5, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['8', 0], latent_image: ['5', 0] } },
|
||||
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'local.safetensors' } },
|
||||
5: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
|
||||
6: { class_type: 'CLIPTextEncode', inputs: { text: POSITIVE_LINES, clip: ['4', 1] } },
|
||||
8: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['6', 0] } },
|
||||
});
|
||||
return file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', prompt), iend()));
|
||||
}
|
||||
|
||||
/** A PNG whose text chunk fails its CRC: the reader reports the metadata unreadable; the bytes stay downloadable. */
|
||||
export const corruptPng = (name = 'corrupt.png') =>
|
||||
file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', '{"3":{}}', { crc: 1 }), iend()));
|
||||
|
||||
/** Bytes no reader claims, under an image or video name. */
|
||||
export const disguised = () => [
|
||||
file('notes.png', 'image/png', Buffer.from('plain text, not an image')),
|
||||
file('page.png', 'image/png', Buffer.from('<!doctype html><title>x</title><p>hi')),
|
||||
file('vector.png', 'image/png', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>')),
|
||||
file('stream.mp4', 'video/mp4', Buffer.from('#EXTM3U\n#EXT-X-VERSION:3\n')),
|
||||
];
|
||||
|
||||
/**
|
||||
* core-faststart.mp4 with its video sample entry renamed from `avc1` to `zzzz`: the container and its tags read as
|
||||
* before, but no browser has a decoder for the stream, so the preview fails deterministically on every engine.
|
||||
*/
|
||||
export function undecodableMp4(name = 'undecodable.mp4') {
|
||||
const bytes = Buffer.from(read('local-inspect', 'core-faststart.mp4'));
|
||||
const at = bytes.indexOf(Buffer.from('avc1', 'latin1'), bytes.indexOf(Buffer.from('stsd', 'latin1')));
|
||||
if (at < 0) throw new Error('core-faststart.mp4 has no avc1 sample entry');
|
||||
bytes.write('zzzz', at, 'latin1');
|
||||
return file(name, 'video/mp4', bytes);
|
||||
}
|
||||
@@ -543,6 +543,33 @@ def create_app(root: Path) -> web.Application:
|
||||
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/add-local-inspect-file")
|
||||
async def add_local_inspect_file(request: web.Request) -> web.Response:
|
||||
"""Serve one committed Inspect fixture from the output folder.
|
||||
|
||||
The local Inspect page reads a chosen file in the browser; its page
|
||||
workflow compares that rail with the Images viewer for the same bytes.
|
||||
Only a committed fixture name is accepted.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
fixtures = CANONICAL_ROOT / "tests" / "fixtures"
|
||||
allowed = {
|
||||
path.name: path
|
||||
for folder in ("inspection_parity", "local-inspect")
|
||||
for path in (fixtures / folder).iterdir()
|
||||
if path.suffix.lower() in {".png", ".jpg", ".webp", ".webm", ".mp4"}
|
||||
}
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"name"}
|
||||
or data["name"] not in allowed
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
destination = root / "output" / "local-inspect" / data["name"]
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(allowed[data["name"]], destination)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/api/settings/Comfy.ColorPalette")
|
||||
async def palette(request: web.Request) -> web.Response:
|
||||
return web.json_response("dark")
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
// The native "Inspect a file" page (#249 local Inspect, PR L2 Task 6): one chosen or dropped local file previews
|
||||
// inline, and the heading's one action opens the shared viewer with the full inspector on a record read entirely in
|
||||
// this browser. The concept is docs/concepts/workspace/app.js renderMetadata (740–748) and its selection (1715–1729);
|
||||
// the production differences are D60–D66 in TESTING.md.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, undecodableMp4, POSITIVE_LINES } from './local-inspect-files.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
|
||||
const ACTION = 'Open file metadata';
|
||||
const COPY = {
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
multiple: 'Drop one file at a time.',
|
||||
directory: 'Folders cannot be inspected. Choose one image or video file.',
|
||||
unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.',
|
||||
previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.',
|
||||
};
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.inspectErrors = [];
|
||||
page.on('pageerror', error => page.inspectErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.inspectErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const action = page => main(page).locator('.heading-actions').getByRole('button', { name: ACTION, exact: true });
|
||||
const chooser = page => page.locator('#local-file');
|
||||
const preview = page => page.locator('#local-preview');
|
||||
const status = page => preview(page).locator('p.small');
|
||||
const viewer = page => page.locator('dialog#viewer-dialog');
|
||||
const rail = page => viewer(page).locator('#viewer-details');
|
||||
|
||||
async function openInspect(page) {
|
||||
await page.goto(`${workspace}#/inspect`);
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
}
|
||||
|
||||
/** Selects through the real chooser, then waits for the page to classify the file. */
|
||||
async function choose(page, file) {
|
||||
await chooser(page).setInputFiles(file);
|
||||
}
|
||||
|
||||
async function openViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
|
||||
function drop(page, selector, { files = [], strings = [] } = {}) {
|
||||
return page.evaluate(async ({ selector, files, strings }) => {
|
||||
const transfer = new DataTransfer();
|
||||
for (const file of files) transfer.items.add(new File([new Uint8Array(file.bytes)], file.name, { type: file.mimeType }));
|
||||
for (const [type, value] of strings) transfer.items.add(value, type);
|
||||
const target = document.querySelector(selector);
|
||||
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
const dropped = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
target.dispatchEvent(over);
|
||||
target.dispatchEvent(dropped);
|
||||
return { over: over.defaultPrevented, drop: dropped.defaultPrevented };
|
||||
}, { selector, files: files.map(file => ({ name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] })), strings });
|
||||
}
|
||||
|
||||
/** The rail's comparable content: prompts, the six facts, the raw records and the two downloads (D66 aside). */
|
||||
const railContent = locator => locator.evaluate(node => {
|
||||
const raw = [...node.querySelectorAll('details')].find(item => item.querySelector('summary')?.textContent === 'Workflow & raw metadata');
|
||||
const facts = node.querySelector(':scope .meta-grid:not(details .meta-grid)');
|
||||
return {
|
||||
positive: node.querySelector('.prompt-section.positive p.prompt-text')?.textContent ?? null,
|
||||
negative: node.querySelector('.prompt-section.negative p.prompt-text')?.textContent ?? null,
|
||||
facts: facts ? [...facts.querySelectorAll(':scope > div')].map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]) : null,
|
||||
raw: raw ? [...raw.querySelectorAll('p.small, pre')].map(item => item.textContent) : null,
|
||||
downloads: raw ? [...raw.querySelectorAll('button')].map(item => [item.textContent, item.getAttribute('aria-disabled')]) : null,
|
||||
};
|
||||
});
|
||||
|
||||
/** Counts storage writes and object URLs created and revoked for Files (download blobs are not the session's). */
|
||||
async function instrument(page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__inspect = { writes: [], created: [], revoked: [], files: new Set() };
|
||||
for (const name of ['setItem', 'removeItem', 'clear']) {
|
||||
const original = Storage.prototype[name];
|
||||
Storage.prototype[name] = function (...args) { window.__inspect.writes.push([name, args[0]]); return original.apply(this, args); };
|
||||
}
|
||||
const open = indexedDB.open.bind(indexedDB);
|
||||
indexedDB.open = (...args) => { window.__inspect.writes.push(['indexedDB.open', args[0]]); return open(...args); };
|
||||
const create = URL.createObjectURL, revoke = URL.revokeObjectURL;
|
||||
URL.createObjectURL = object => {
|
||||
const url = create(object);
|
||||
if (object instanceof File) { window.__inspect.created.push(url); window.__inspect.files.add(url); }
|
||||
return url;
|
||||
};
|
||||
URL.revokeObjectURL = url => { if (window.__inspect.files.has(url)) window.__inspect.revoked.push(url); return revoke(url); };
|
||||
});
|
||||
}
|
||||
const urls = page => page.evaluate(() => ({ created: [...window.__inspect.created], revoked: [...window.__inspect.revoked], writes: [...window.__inspect.writes] }));
|
||||
|
||||
test('chooser opens the shared full inspector without a second read', async ({ page, request }) => {
|
||||
// The Images viewer's rail for the same bytes, served from the output folder.
|
||||
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
|
||||
const served = await railContent(rail(page));
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(main(page).locator('.heading p.muted')).toHaveText('A standalone file uses the same image viewer and prompt actions.');
|
||||
// The concept DOM: one surface, the drop area with its icon, label, input and note, then the preview slot.
|
||||
const surface = main(page).locator('section.surface');
|
||||
await expect(surface).toHaveCount(1);
|
||||
await expect(surface.locator('h2')).toHaveText('Inspect without importing');
|
||||
await expect(surface.locator(':scope > p')).toHaveText(COPY.intro);
|
||||
const area = surface.locator('div.file-drop.spaced-20');
|
||||
await expect(area.locator(':scope > svg')).toHaveCount(1);
|
||||
await expect(area.locator('label[for="local-file"]')).toHaveText(COPY.chooser);
|
||||
await expect(chooser(page)).toHaveAttribute('type', 'file');
|
||||
await expect(chooser(page)).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv');
|
||||
await expect(chooser(page)).not.toHaveAttribute('multiple', /.*/);
|
||||
await expect(area.locator('span.small#local-file-note')).toHaveText(COPY.note);
|
||||
await expect(surface.locator(':scope > #local-preview')).toBeEmpty();
|
||||
// D60: the one heading action is described by the note until a file is chosen.
|
||||
await expect(main(page).locator('.heading-actions button')).toHaveCount(1);
|
||||
await expect(action(page)).toHaveClass(/\bca-primary\b/);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(action(page)).toHaveAccessibleDescription(COPY.note);
|
||||
await action(page).dispatchEvent('click');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
|
||||
await choose(page, parityPng('f02.png'));
|
||||
const image = preview(page).locator('img.local-file-preview');
|
||||
await expect(image).toHaveAttribute('alt', 'Local file preview');
|
||||
await expect(image).toHaveJSProperty('complete', true);
|
||||
expect(await image.evaluate(node => node.naturalWidth)).toBe(64);
|
||||
expect(await image.getAttribute('src')).toMatch(/^blob:/);
|
||||
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
|
||||
expect(box.height).toBeLessThanOrEqual(400);
|
||||
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
|
||||
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
const position = viewer(page).locator('.media-position');
|
||||
await expect(position).toHaveText('1 of 1 loaded: f02.png');
|
||||
for (const name of ['Previous media', 'Next media', 'Play slideshow']) {
|
||||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
await expect(viewer(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
for (const name of ['Fit image', 'Rotate image 90 degrees', 'Flip image horizontally']) {
|
||||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||||
}
|
||||
const canvas = viewer(page).locator('.viewer-canvas img');
|
||||
await viewer(page).getByRole('button', { name: 'Rotate image 90 degrees', exact: true }).click();
|
||||
await expect(canvas).toHaveAttribute('style', /rotate\(90deg\)/);
|
||||
await viewer(page).getByRole('button', { name: 'Flip image horizontally', exact: true }).click();
|
||||
await expect(canvas).toHaveAttribute('style', /scaleX\(-1\)/);
|
||||
await viewer(page).getByRole('button', { name: 'Fit image', exact: true }).click();
|
||||
await expect(canvas).not.toHaveAttribute('style', /rotate\(90deg\)/);
|
||||
|
||||
// The same rail as Images for the same bytes, except the D66 provenance.
|
||||
const local = await railContent(rail(page));
|
||||
expect(local).toEqual(served);
|
||||
expect(local.positive).toBe('a red fox in snow, detailed fur');
|
||||
expect(local.negative).toBe('blurry, lowres');
|
||||
expect(local.facts.map(([label]) => label)).toEqual(['Seed', 'Steps', 'CFG', 'Sampler', 'Scheduler', 'Model']);
|
||||
expect(local.downloads).toEqual([['Download metadata JSON', null], ['Download workflow JSON', null]]);
|
||||
const provenance = rail(page).locator('details').filter({ has: page.locator('summary', { hasText: 'File & provenance' }) });
|
||||
await expect(provenance).toContainText('Selected local file · read in this browser');
|
||||
await expect(provenance).toContainText('Local file; no saved library association was checked.');
|
||||
await expect(provenance.locator('dt', { hasText: /^Path$/ })).toHaveCount(0);
|
||||
await expect(rail(page).getByRole('button', { name: 'Show in folder', exact: true })).toHaveCount(0);
|
||||
await expect(rail(page).getByRole('button', { name: /Prompt history/ })).toHaveCount(0);
|
||||
await expect(rail(page).locator('.chip-row button.chip')).toHaveCount(0);
|
||||
const original = viewer(page).getByRole('link', { name: 'Download original', exact: true });
|
||||
await expect(original).toHaveAttribute('href', /^blob:/);
|
||||
await expect(original).toHaveAttribute('download', 'f02.png');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(action(page)).toBeFocused();
|
||||
// Opening the viewer again shows the same record without reading the File a second time.
|
||||
await page.evaluate(() => {
|
||||
window.__slices = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
|
||||
});
|
||||
await openViewer(page);
|
||||
expect(await railContent(rail(page))).toEqual(served);
|
||||
expect(await page.evaluate(() => window.__slices)).toBe(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();
|
||||
const dialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||||
await dialog.getByRole('button', { name: 'Open inspector', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
await expect(main(page).locator('.handoff')).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// A drop that misses the area is cancelled, so the browser never opens the file in place of the page.
|
||||
expect(await drop(page, 'body', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page)).toBeEmpty();
|
||||
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// Two files, a link and markup keep the selection and say why.
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
await expect(status(page)).toHaveText(COPY.multiple);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
for (const strings of [[['text/uri-list', 'https://example.invalid/a.png']], [['text/html', '<img src="https://example.invalid/a.png">']]]) {
|
||||
await drop(page, '.file-drop', { strings });
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
}
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
});
|
||||
|
||||
test('cancel and same-file reselect', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
|
||||
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
|
||||
await chooser(page).setInputFiles([]);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
await expect(status(page)).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// Opening the picker and cancelling it keeps naming the live selection (modern engines fire `cancel`, not `change`).
|
||||
await chooser(page).dispatchEvent('click');
|
||||
await chooser(page).dispatchEvent('cancel');
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// The same file again is a new selection with its own URL; the old one is released. Opening the picker clears
|
||||
// the chooser, so the browser reports the same file as a change.
|
||||
// (Read in the same task: a picker the click opens is cancelled by the harness, which names the file again.)
|
||||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img')).not.toHaveAttribute('src', first);
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
const state = await urls(page);
|
||||
expect(state.created).toHaveLength(2);
|
||||
expect(state.revoked).toEqual([first]);
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
|
||||
});
|
||||
|
||||
test('preview does not imply metadata support', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
// JPEG and WebP preview and report no supported metadata, exactly as Images reports them.
|
||||
for (const [file, format] of [[localFixture('tiny.jpg', 'image/jpeg'), 'JPEG'], [localFixture('tiny.webp', 'image/webp'), 'WEBP']]) {
|
||||
await choose(page, file);
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||||
await expect(rail(page).locator('details').first()).toContainText(format);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
// A PNG whose text chunk fails its CRC: unreadable metadata, the original still downloads.
|
||||
await choose(page, corruptPng());
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('Embedded metadata could not be read.');
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), viewer(page).getByRole('link', { name: 'Download original', exact: true }).click()]);
|
||||
expect(download.suggestedFilename()).toBe('corrupt.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
|
||||
const before = (await urls(page)).created.length;
|
||||
for (const file of disguised()) {
|
||||
await choose(page, file);
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
expect((await urls(page)).created).toHaveLength(before);
|
||||
});
|
||||
|
||||
test('local video plays inline and in the viewer with real tags', async ({ page, browserName }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
const video = preview(page).locator('video.local-file-preview');
|
||||
await expect(video).toBeVisible();
|
||||
expect(await video.evaluate(node => ({ controls: node.controls, playsInline: node.playsInline, preload: node.preload, muted: node.muted, autoplay: node.autoplay, paused: node.paused })))
|
||||
.toEqual({ controls: true, playsInline: true, preload: 'metadata', muted: true, autoplay: false, paused: true });
|
||||
// WebKit may not decode VP8: whichever state it reports, metadata is still read.
|
||||
await expect.poll(() => video.evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await choose(page, tinyWebm());
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||||
if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('the toast and a rejected drop never contradict the status line', async ({ page }) => {
|
||||
// A file whose bytes are gone after choosing: the status says its metadata cannot be read, so no toast says it is
|
||||
// available.
|
||||
await page.addInitScript(() => {
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||||
return slice.apply(this, args);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
// A rejected drop is its own alert beside the selection's status, which it never replaces, and it ends with the
|
||||
// concept's toast interval.
|
||||
await choose(page, undecodableMp4());
|
||||
const failed = status(page).filter({ hasText: COPY.previewFailed });
|
||||
await expect(failed).toHaveCount(1);
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
const rejected = status(page).filter({ hasText: COPY.multiple });
|
||||
await expect(rejected).toHaveAttribute('role', 'alert');
|
||||
await expect(failed).toHaveCount(1);
|
||||
await expect(rejected).toHaveCount(0, { timeout: 15000 });
|
||||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||||
// The next selection attempt ends a rejection at once.
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
await expect(rejected).toHaveCount(1);
|
||||
await chooser(page).dispatchEvent('click');
|
||||
await chooser(page).dispatchEvent('cancel');
|
||||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||||
});
|
||||
|
||||
test('undecodable video keeps container metadata', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact.
|
||||
await choose(page, undecodableMp4());
|
||||
await expect(status(page)).toHaveText(COPY.previewFailed);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
});
|
||||
|
||||
/** Records, for each revoked URL, how many elements (inline or in the viewer) still held it, and counts resets. */
|
||||
const trackReleases = page => page.evaluate(() => {
|
||||
window.__released = [];
|
||||
const revoke = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = url => {
|
||||
const live = [...document.querySelectorAll('img, video')].filter(node => node.getAttribute('src') === url);
|
||||
window.__released.push({ url, live: live.length });
|
||||
return revoke(url);
|
||||
};
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
window.__loads = 0;
|
||||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
|
||||
});
|
||||
|
||||
test('URLs and media end with their owning session', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
// Every release pauses the media and drops its src before the URL is revoked.
|
||||
await trackReleases(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('video')).toBeVisible();
|
||||
// A settled video (metadata read, or failed) is reset with load() when it is released.
|
||||
await expect.poll(() => preview(page).locator('video').evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||||
// Replacement.
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
let state = await urls(page);
|
||||
expect(state.revoked).toEqual([state.created[0]]);
|
||||
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
|
||||
// Route change.
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
state = await urls(page);
|
||||
expect(state.revoked).toEqual(state.created);
|
||||
// Five mount/unmount cycles with a selection and an opened viewer leave no live URL and no dialog.
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await choose(page, parityPng('f02.png', `cycle-${cycle}.png`));
|
||||
await openViewer(page);
|
||||
// The modal viewer makes the sidebar inert, so the route changes underneath it.
|
||||
await page.evaluate(() => { location.hash = '#/overview'; });
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
}
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(7);
|
||||
expect([...state.revoked].sort()).toEqual([...state.created].sort());
|
||||
expect(new Set(state.revoked).size).toBe(state.revoked.length);
|
||||
expect((await page.evaluate(() => window.__released)).every(entry => entry.live === 0)).toBe(true);
|
||||
await expect(page.locator('dialog')).toHaveCount(0);
|
||||
// Page leave releases the selection once.
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await choose(page, parityPng('f02.png', 'leave.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(8);
|
||||
expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1);
|
||||
// Page leave with the viewer open, on a page loaded at Inspect (so its listeners precede the shell's): the viewer
|
||||
// closes and releases its media before the URL is revoked, and a page restored from the back-forward cache shows
|
||||
// an empty chooser beside the empty selection.
|
||||
await page.reload();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
await trackReleases(page);
|
||||
await choose(page, parityPng('f02.png', 'leave-open.png'));
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).getByRole('button', { name: 'Zoom in', exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
|
||||
await expect(page.locator('dialog')).toHaveCount(0);
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(1);
|
||||
expect(state.revoked).toEqual(state.created);
|
||||
expect(await page.evaluate(() => window.__released)).toEqual([{ url: state.created[0], live: 0 }]);
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
expect(await chooser(page).evaluate(node => ({ value: node.value, files: node.files.length }))).toEqual({ value: '', files: 0 });
|
||||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('replacing a video that just started loading never blocks the page', async ({ context, browserName }) => {
|
||||
test.setTimeout(120000);
|
||||
// WebKit (GStreamer) blocked the page when a video whose pipeline was still starting was reset with load(): the
|
||||
// next chooser selection, or the steps after it, never completed. Each fresh page replaces its first video as
|
||||
// soon as it shows.
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
|
||||
// A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any
|
||||
// reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's
|
||||
// no-hang guard is `replacing a video that just started loading never blocks the page`.
|
||||
test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)');
|
||||
test.setTimeout(120000);
|
||||
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before
|
||||
// its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or
|
||||
// an error, so the page never blocks. Fresh pages share one browser process.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await page.addInitScript(() => {
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) this.__resets = (this.__resets ?? 0) + 1; return load.call(this); };
|
||||
const revoke = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = url => {
|
||||
const starting = window.__starting;
|
||||
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
|
||||
return revoke(url);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => {
|
||||
const input = document.querySelector('#local-file'), slot = document.querySelector('#local-preview');
|
||||
new MutationObserver((records, observer) => {
|
||||
const video = slot.querySelector('video');
|
||||
if (!video) return;
|
||||
observer.disconnect();
|
||||
video.addEventListener('loadstart', () => {
|
||||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, { once: true });
|
||||
}).observe(slot, { childList: true });
|
||||
});
|
||||
const png = parityPng('f02.png');
|
||||
await page.evaluate(bytes => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||||
window.__pngFiles = transfer.files;
|
||||
}, [...png.buffer]);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
// Released while starting, and never reset synchronously: the URL went once the node had no src.
|
||||
expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked })))
|
||||
.toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } });
|
||||
await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1);
|
||||
// The page still answers.
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await openViewer(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0, { timeout: 8000 });
|
||||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => {
|
||||
const host = await bridgeHost(context);
|
||||
try {
|
||||
await openInspect(page);
|
||||
// Hold every read of held.png after its classification prefix.
|
||||
await page.evaluate(() => {
|
||||
let release;
|
||||
window.__gate = new Promise(resolve => { release = resolve; });
|
||||
window.__releaseGate = release;
|
||||
window.__held = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
const part = slice.apply(this, args);
|
||||
if (this.name !== 'held.png' || (args[0] ?? 0) === 0 && (args[1] ?? this.size) <= 32) return part;
|
||||
const read = part.arrayBuffer.bind(part);
|
||||
part.arrayBuffer = async () => { window.__held++; await window.__gate; return read(); };
|
||||
return part;
|
||||
};
|
||||
});
|
||||
await choose(page, parityPng('f03.png', 'held.png'));
|
||||
await expect.poll(() => page.evaluate(() => window.__held)).toBeGreaterThan(0);
|
||||
await choose(page, sendablePng('fresh.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await page.evaluate(() => window.__releaseGate());
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('fresh.png');
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||||
await sendPair(page);
|
||||
expect(await host.evaluate(() => window.inspectApplied)).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||||
} finally {
|
||||
await host.evaluate(() => window.inspectHost.dispose());
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** A second tab acting as a ComfyUI host with one Prompt Manager pair destination. */
|
||||
async function bridgeHost(context) {
|
||||
const host = await context.newPage();
|
||||
await host.goto(`${workspace}#/overview`);
|
||||
await host.evaluate(async () => {
|
||||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target = { id: 'pair-1', label: 'Inspect destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
|
||||
window.inspectApplied = [];
|
||||
window.inspectPreviews = [];
|
||||
window.inspectHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Inspect ComfyUI', adapter: {
|
||||
listTargets: () => [target],
|
||||
preview: (_id, roles) => {
|
||||
window.inspectPreviews.push(roles);
|
||||
return { preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: `Previous ${role}`, after })),
|
||||
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: 'Previous positive', after: roles.positive ?? 'Previous positive' } }, snapshot: roles };
|
||||
},
|
||||
apply: roles => { window.inspectApplied.push(roles); return { targetId: target.id, values: roles }; },
|
||||
} });
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
async function sendPair(page) {
|
||||
await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
|
||||
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.');
|
||||
await sender.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
}
|
||||
|
||||
test('local roles send exactly', async ({ page, context }) => {
|
||||
const host = await bridgeHost(context);
|
||||
try {
|
||||
await openInspect(page);
|
||||
await choose(page, sendablePng());
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||||
await expect(rail(page).locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await sendPair(page);
|
||||
const applied = await host.evaluate(() => window.inspectApplied);
|
||||
expect(applied).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||||
const payload = JSON.stringify([applied, await host.evaluate(() => window.inspectPreviews)]);
|
||||
expect(payload).not.toContain('blob:');
|
||||
expect(payload).not.toContain('send-me');
|
||||
// The viewer is still open, focus came back to the send action.
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
} finally {
|
||||
await host.evaluate(() => window.inspectHost.dispose());
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
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 }));
|
||||
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
|
||||
await choose(page, file);
|
||||
await openViewer(page);
|
||||
await rail(page).locator('summary', { hasText: 'Workflow & raw metadata' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
|
||||
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([]);
|
||||
expect((await urls(page)).writes.slice(baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
for (const zoom of ['1', '2']) {
|
||||
await page.evaluate(value => { document.documentElement.style.zoom = value; }, zoom);
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
const area = await page.locator('.file-drop').evaluate(node => node.getBoundingClientRect().right <= document.documentElement.clientWidth + 0.5);
|
||||
expect(area).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,8 @@ const routes = [
|
||||
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
|
||||
['library', 'Prompt library', null],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
|
||||
['inspect', 'Inspect a file', null],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
@@ -80,7 +81,8 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (route === 'tags' || route === 'inspect') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (route === 'inspect') await expect(main.locator('#local-file')).toBeAttached();
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
await expect(await request.get(legacy.split('#')[0])).toBeOK();
|
||||
@@ -93,6 +95,10 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
|
||||
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
|
||||
await expect(await request.get('/prompt_manager/admin')).toBeOK();
|
||||
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
|
||||
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
|
||||
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
|
||||
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
|
||||
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
import {sendablePng,localFixture,POSITIVE_LINES} from '../browser/local-inspect-files.mjs';
|
||||
|
||||
// PR L2 Task 7 of the local-Inspect plan (#249): a file read entirely in the browser by the native Inspect page sends
|
||||
// its exact roles into a real Prompt Manager (+/-) node, in Classic and Nodes 2.0, switching both ways and reloading
|
||||
// the graph each time. Nothing queues or loads a workflow, and a connected destination input rejects the whole send.
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
|
||||
// The PNG's positive spans lines and its negative is a ConditioningZeroOut (explicitly empty); the WebM carries the
|
||||
// committed core video's per-key tags.
|
||||
const FILES=[
|
||||
{file:()=>sendablePng('host-send.png'),positive:POSITIVE_LINES,negative:'',shown:'No negative prompt (explicitly empty)'},
|
||||
{file:()=>localFixture('core-tags.webm','video/webm','host-send.webm'),positive:'a red fox in snow, detailed fur',negative:'blurry, lowres',shown:'blurry, lowres'},
|
||||
];
|
||||
|
||||
async function nodeState(page,id) {
|
||||
return page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
return {inputs:(await app.graphToPrompt()).output[String(id)].inputs,widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.inspectEffects};
|
||||
},id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every queue or workflow load until `restore` runs; with `reset`, first sets the destination's roles back to
|
||||
* known values (the connected-input case sets them itself, before it links the input, as library-sender.spec does).
|
||||
*/
|
||||
async function watchHost(page,id,reset=true) {
|
||||
await page.evaluate(async ({id,reset})=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
|
||||
if(reset){node.widgets.find(widget=>widget.name==='text').value='Before positive';node.widgets.find(widget=>widget.name==='negative_text').value='Before negative';}
|
||||
window.inspectEffects={queue:0,load:0};window.inspectOriginals={};
|
||||
for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.inspectOriginals[method]=original;app[method]=function(...args){window.inspectEffects[key]++;return original.apply(this,args);};}
|
||||
},{id,reset});
|
||||
}
|
||||
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
|
||||
|
||||
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
|
||||
async function inspect(workspace,{file,positive,shown}) {
|
||||
await workspace.locator('#local-file').setInputFiles(file());
|
||||
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
|
||||
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
|
||||
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
|
||||
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
|
||||
return rail;
|
||||
}
|
||||
|
||||
/** The rail's pair send, through to a previewed destination; returns the sender dialog. */
|
||||
async function preview(workspace,rail) {
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).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();
|
||||
return sender;
|
||||
}
|
||||
|
||||
async function closeViewer(workspace,sender) {
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
|
||||
await workspace.keyboard.press('Escape');await expect(workspace.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('local file roles send exactly across renderer transitions',async({page,context},testInfo)=>{
|
||||
test.setTimeout(180000);
|
||||
const id=await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
|
||||
node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;
|
||||
});
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
const requests=[];workspace.on('request',request=>{if(request.method()!=='GET')requests.push(`${request.method()} ${request.url()}`);});
|
||||
const logged=[];workspace.on('console',message=>logged.push(message.text()));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/inspect');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Inspect a file');
|
||||
const initial=testInfo.project.metadata.initialRenderer;
|
||||
for(const renderer of [initial,!initial,initial]) {
|
||||
// The renderer switch reloads the graph, as a workflow reload does.
|
||||
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());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
for(const entry of FILES) {
|
||||
await watchHost(page,id);
|
||||
try {
|
||||
const rail=await inspect(workspace,entry);
|
||||
const sender=await preview(workspace,rail);
|
||||
for(const text of ['Before positive','Before negative',...entry.positive.split('\n'),entry.negative||'(empty)'])await expect(sender).toContainText(text);
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
const state=await nodeState(page,id);
|
||||
expect(state.effects).toEqual({queue:0,load:0});
|
||||
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:entry.positive,negative_text:entry.negative});
|
||||
await closeViewer(workspace,sender);
|
||||
} finally {await restore(page);}
|
||||
}
|
||||
}
|
||||
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
|
||||
const link=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
|
||||
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
|
||||
node.widgets.find(item=>item.name==='text').value='Before positive';widget.value='Before negative';
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(item=>item.name==='value').value='Upstream negative';
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
|
||||
return [String(source.id),0];
|
||||
},id);
|
||||
await watchHost(page,id,false);
|
||||
try {
|
||||
const before=await nodeState(page,id);
|
||||
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
const rail=await inspect(workspace,FILES[1]);
|
||||
const sender=await preview(workspace,rail);
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await nodeState(page,id);
|
||||
expect(rejected.effects).toEqual({queue:0,load:0});
|
||||
expect(rejected.inputs.negative_text).toEqual(link);
|
||||
for(const field of ['widgets','serialized'])expect(rejected[field]).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
await closeViewer(workspace,sender);
|
||||
} finally {await restore(page);}
|
||||
// The workspace page sent nothing to the server (the only crossing is the host bridge) and logged nothing of
|
||||
// the files: no name and no prompt text.
|
||||
expect(requests).toEqual([]);
|
||||
expect(logged.filter(text=>/host-send|lighthouse|long exposure|red fox|lowres/.test(text))).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// A minimal DOM for mounting the workspace viewer and inspector in Node: elements with
|
||||
// children, attributes, listeners (fire() dispatches), dialogs and media methods.
|
||||
export function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
@@ -87,6 +87,104 @@ test('Show in folder outcomes use the D38 toasts',async()=>{
|
||||
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
|
||||
});
|
||||
|
||||
// PR L2 Task 4(c): a record read in this browser renders the D66 local rail.
|
||||
const {mountedDom} = await import('./helpers/mounted-dom.mjs');
|
||||
const {createScope} = await import('../../web/js/shared/toolkit/core.js');
|
||||
const {createRequire} = await import('node:module');
|
||||
const fs = await import('node:fs');
|
||||
const require = createRequire(import.meta.url);
|
||||
const PngMetadata = require('../../web/js/png-metadata.js'), ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
const {raw:pngRaw, ihdr, idat, iend, tEXt} = await import('./helpers/png-bytes.mjs');
|
||||
const LOCAL_PROMPT = '{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}}}';
|
||||
const LOCAL_URL = 'blob:http://localhost/11111111-2222-3333-4444-555555555555';
|
||||
const PARITY = new URL('../fixtures/inspection_parity/', import.meta.url);
|
||||
|
||||
async function localRecord(file) {
|
||||
const local = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const previous = {png:globalThis.PngMetadata, comfy:globalThis.ComfyMetadata};
|
||||
globalThis.PngMetadata = PngMetadata; globalThis.ComfyMetadata = ComfyMetadata;
|
||||
try {
|
||||
const classification = PngMetadata.classifyPrefix(new Uint8Array(await file.slice(0, 32).arrayBuffer()));
|
||||
const handle = local.createLocalFileHandle({file, url:LOCAL_URL, classification});
|
||||
return local.localFileAdapter.toRecord(await local.localFileAdapter.read(handle), handle, ComfyMetadata.extractGenerationFacts);
|
||||
} finally {
|
||||
if (previous.png === undefined) delete globalThis.PngMetadata; else globalThis.PngMetadata = previous.png;
|
||||
if (previous.comfy === undefined) delete globalThis.ComfyMetadata; else globalThis.ComfyMetadata = previous.comfy;
|
||||
}
|
||||
}
|
||||
const localPng = name => new File([pngRaw(ihdr(640, 480), idat(), tEXt('prompt', LOCAL_PROMPT), iend())], name, {type:'image/png', lastModified:1_700_000_000_000});
|
||||
function renderFull(record) {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
const forbidden = new Proxy({}, {get(_, key) {assert.fail(`api.${String(key)} touched by a local record`);}});
|
||||
const view = mountFileInspector(f.container, {api:forbidden, sender:{open() {}}, parentScope:scope, variant:'full'});
|
||||
view.setState({loading:false, error:false, record});
|
||||
const texts = predicate => f.walk(f.container, predicate).map(node => node.textContent);
|
||||
return {f, texts, done() {view.dispose(); scope.dispose(); f.restore();}};
|
||||
}
|
||||
const {mountFileInspector} = await load();
|
||||
|
||||
test('local record renders local provenance', async () => {
|
||||
const record = await localRecord(localPng('Fox Render.png'));
|
||||
const {f, texts, done} = renderFull(record);
|
||||
try {
|
||||
const section = f.walk(f.container, n => n.tagName === 'details' && n.children[0]?.textContent === 'File & provenance')[0];
|
||||
assert.ok(section, 'the full variant has File & provenance');
|
||||
const lines = f.walk(section, n => n.tagName === 'p').map(n => n.textContent);
|
||||
assert.equal(lines[0], 'Selected local file · read in this browser');
|
||||
assert.ok(lines.includes('Local file; no saved library association was checked.'));
|
||||
assert.ok(!lines.some(line => /historical bytes|saved positive prompt|Saved association/.test(line)));
|
||||
assert.deepEqual(f.walk(section, n => n.tagName === 'dt').map(n => n.textContent), ['Source', 'Size', 'Dimensions', 'Format', 'Media type', 'Modified']);
|
||||
const values = Object.fromEntries(f.walk(section, n => n.tagName === 'dl')[0].children.map(row => [row.children[0].textContent, row.children[1].textContent]));
|
||||
assert.equal(values.Source, 'Selected local file');
|
||||
assert.equal(values.Dimensions, '640 × 480');
|
||||
assert.equal(values.Format, 'PNG');
|
||||
assert.equal(values['Media type'], 'image');
|
||||
assert.equal(texts(n => n.tagName === 'button' && /Show .* in folder|Show in folder/.test(n.textContent)).length, 0);
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'button' && n.textContent.includes('Prompt history')).length, 0);
|
||||
const original = f.walk(section, n => n.tagName === 'a' && n.textContent === 'Download original')[0];
|
||||
assert.ok(original, 'Download original is present');
|
||||
assert.equal(original.href, LOCAL_URL);
|
||||
assert.equal(original.download, 'Fox Render.png');
|
||||
// The heading names the file itself; no library title replaces it.
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'h2')[0].textContent, 'Fox Render.png');
|
||||
} finally { done(); }
|
||||
});
|
||||
|
||||
test('local record shows no image-tag chips', async () => {
|
||||
const record = await localRecord(localPng('a.png'));
|
||||
assert.equal(record.library.status, 'unavailable');
|
||||
// A local payload carries no image_tags facet (#281), so the record has none and only linked chips could show.
|
||||
assert.equal(record.imageTags, null);
|
||||
const {inspectorTagChips} = await load();
|
||||
assert.deepEqual(inspectorTagChips(record, {filterable:true}), []);
|
||||
const {f, done} = renderFull(record);
|
||||
try {
|
||||
// Only the format chip and the D21 evidence badge: no category, saved tag or image-tag chips.
|
||||
const chips = f.walk(f.container, n => /(^| )chip( |$)/.test(n.className ?? '')).map(n => n.textContent);
|
||||
assert.deepEqual(chips, ['PNG', 'Workflow inferred']);
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'button' && /^Tag/.test(n.textContent)).length, 0);
|
||||
} finally { done(); }
|
||||
});
|
||||
|
||||
test('legacy fill applies to local records', async () => {
|
||||
// f09: the server's inspection is ambiguous; the local record shows the legacy parse, labelled as inferred.
|
||||
const ambiguous = await localRecord(new File([fs.readFileSync(new URL('f09.png', PARITY))], 'f09.png'));
|
||||
const graphs = {prompt:ambiguous.metadata.prompt_graph.value, workflow:ambiguous.metadata.workflow_graph.status === 'parsed' ? ambiguous.metadata.workflow_graph.value : null};
|
||||
const legacy = ComfyMetadata.extractGenerationParams(graphs);
|
||||
assert.deepEqual(ambiguous.prompts.positive, {status:'resolved', text:legacy.positivePrompt, source:'graph-inferred'});
|
||||
assert.deepEqual(ambiguous.prompts.negative, {status:'resolved', text:legacy.negativePrompt, source:'graph-inferred'});
|
||||
assert.equal(ambiguous.prompts.positive.text, 'a red fox in snow, detailed fur');
|
||||
const view = renderFull(ambiguous);
|
||||
try {
|
||||
assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Workflow inferred']);
|
||||
assert.ok(view.texts(n => n.tagName === 'p').includes('Prompt evidence: workflow inferred'));
|
||||
} finally { view.done(); }
|
||||
// f01b: the legacy parse would read the positive text through ConditioningZeroOut; the guard shows the zeroed negative.
|
||||
const zeroed = await localRecord(new File([fs.readFileSync(new URL('f01b.png', PARITY))], 'f01b.png'));
|
||||
assert.deepEqual(zeroed.prompts.negative, {status:'resolved', text:'', source:'zeroed-conditioning'});
|
||||
assert.notEqual(zeroed.prompts.positive.text, '');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
|
||||
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
|
||||
return {...record(), imageTags, library};
|
||||
|
||||
@@ -703,6 +703,75 @@ test('a workflow the legacy parser throws on keeps the server result instead of
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
});
|
||||
|
||||
// PR L2 Task 4(a): an adapter that reads by itself (the local file adapter) never reaches the API.
|
||||
test('controller prefers adapter.read and never touches api', async () => {
|
||||
const createController = contract('createInspectionController');
|
||||
const forbidden = new Proxy({}, { get(_, key) { assert.fail(`api.${String(key)} touched`); } });
|
||||
const reads = [], records = [];
|
||||
const adapter = {
|
||||
validate(item) { if (!item?.key) throw new TypeError('bad'); }, key: item => item.key, scope: () => 'own', inspectionPath: item => `own:${item.key}`,
|
||||
read(item, options) { const pending = {}; reads.push({ item, options, pending }); return new Promise((resolve, reject) => Object.assign(pending, { resolve, reject })); },
|
||||
toRecord(result, item) { records.push([result, item.key]); return { identity: { kind: 'own', key: item.key }, prompts: result.prompts }; },
|
||||
};
|
||||
for (const api of [forbidden, null]) {
|
||||
reads.length = 0; records.length = 0;
|
||||
const controller = createController({ api, adapter, extractFacts: extractGenerationFacts });
|
||||
const first = controller.select({ key: 'a' });
|
||||
assert.equal(reads.length, 1);
|
||||
assert.equal(reads[0].item.key, 'a');
|
||||
assert.ok(reads[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(controller.state.loading, true);
|
||||
const second = controller.select({ key: 'b' });
|
||||
assert.equal(reads[0].options.signal.aborted, true, 'a new selection aborts the previous read');
|
||||
reads[0].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'late' } } });
|
||||
await first;
|
||||
assert.equal(controller.state.record, null, 'a superseded read never publishes');
|
||||
reads[1].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'current' } } });
|
||||
await second;
|
||||
assert.deepEqual(records.map(([, key]) => key), ['b']);
|
||||
assert.equal(controller.state.record.prompts.positive.text, 'current');
|
||||
const retry = controller.retry();
|
||||
reads[2].pending.reject(new Error('read failed'));
|
||||
await retry;
|
||||
assert.equal(controller.state.error, true);
|
||||
assert.equal(controller.state.message, 'Could not read details from this file.');
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('rooted and history adapters are unchanged', async () => {
|
||||
const createController = contract('createInspectionController');
|
||||
const { reportedLocationAdapter } = await import('../../web/js/workspace/history-media.mjs');
|
||||
for (const adapter of [module.rootedFileAdapter, reportedLocationAdapter]) {
|
||||
assert.equal(adapter.read, undefined, 'server adapters read through the API');
|
||||
assert.equal(adapter.mediaUrl, undefined, 'server adapters keep the cache-busted original');
|
||||
}
|
||||
const { api, requests } = pendingApi();
|
||||
const rooted = createController({ api, extractFacts: extractGenerationFacts });
|
||||
rooted.select(image());
|
||||
assert.deepEqual(requests.map(request => request.url), [`/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`]);
|
||||
rooted.dispose();
|
||||
const run = '22222222-2222-2222-2222-222222222222', epoch = '11111111-1111-1111-1111-111111111111';
|
||||
const query = new URLSearchParams({ node: '1', ordinal: '1', epoch, current_scope: scopeA }), base = `/prompt_manager/history/runs/${run}/output/current`;
|
||||
const target = { kind: 'reported-location', location_key: 'c'.repeat(64), history_epoch: epoch, current_scope: scopeA, occurrence: { run_id: run, output_node_id: '1', image_ordinal: 1 },
|
||||
reference: { folder_type: 'output', subfolder: '', filename: 'a.png', path_style: 'posix', root_key: scopeB }, filename: 'a.png', is_video: false,
|
||||
url: `${base}/media?${query}`, thumbnail_url: `${base}/thumbnail?${query}`, inspection_url: `${base}/inspection?${query}` };
|
||||
const history = pendingApi(), recorded = createController({ api: history.api, adapter: reportedLocationAdapter, extractFacts: extractGenerationFacts });
|
||||
recorded.select(target);
|
||||
assert.deepEqual(history.requests.map(request => request.url), [target.inspection_url]);
|
||||
recorded.dispose();
|
||||
});
|
||||
|
||||
test('fileFor rejects selected-local-file for rooted identity', () => {
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.file = { ...data.file, observation: 'selected-local-file', source_label: 'Selected local file' };
|
||||
assert.throws(() => contract('toInspectionRecord')(data, entry, extractGenerationFacts), /Invalid inspection file/);
|
||||
const project = contract('projectInspectionPayload');
|
||||
assert.throws(() => project(data, { kind: 'rooted-file', key: 'k', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.throws(() => project(envelope(entry), { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.equal(project(data, { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts).file.observation, 'selected-local-file');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
|
||||
test('image_tags facet is kept exactly and absent stays null', () => {
|
||||
const entry = image(), project = contract('toInspectionRecord');
|
||||
|
||||
@@ -267,9 +267,10 @@ test("select reuses the same local key", async () => {
|
||||
assert.equal(m.localFileAdapter.inspectionPath(first), `local-file:${first.key}`);
|
||||
assert.equal(m.localFileAdapter.key(first), first.key);
|
||||
const reads = [];
|
||||
// Until the loader seam lands (PR L2 Task 4) the controller reaches the adapter through api.get.
|
||||
const api = { get: (sentinel) => { reads.push(sentinel); return m.localFileAdapter.read(reads.length === 1 || sentinel === `local-file:${first.key}` ? first : second); } };
|
||||
const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter: m.localFileAdapter });
|
||||
// The controller calls adapter.read itself (PR L2 Task 4); the API is never reached.
|
||||
const api = new Proxy({}, { get: (_, key) => assert.fail(`api.${String(key)} touched`) });
|
||||
const adapter = { ...m.localFileAdapter, read: (handle, options) => { reads.push(m.localFileAdapter.inspectionPath(handle)); return m.localFileAdapter.read(handle, options); } };
|
||||
const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter });
|
||||
const pending = controller.select(first);
|
||||
assert.equal(controller.select(first), pending, "the same handle returns the pending read");
|
||||
await pending;
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// web/js/workspace/local-session.mjs owns one local selection at a time: it
|
||||
// classifies the chosen File by its bytes, creates the blob: URL only for a
|
||||
// classified file, hands an owned handle to a real inspection controller, and
|
||||
// releases the URL, the handle and the record on replacement, dispose, parent
|
||||
// abort and page leave.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { raw, ihdr, idat, iend, tEXt } from "./helpers/png-bytes.mjs";
|
||||
import { createScope } from "../../web/js/shared/toolkit/core.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const PngMetadata = require("../../web/js/png-metadata.js");
|
||||
const ComfyMetadata = require("../../web/js/comfy-metadata.js");
|
||||
globalThis.PngMetadata = PngMetadata;
|
||||
globalThis.ComfyMetadata = ComfyMetadata;
|
||||
const m = await import("../../web/js/workspace/local-session.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
const { localFileAdapter } = await import("../../web/js/workspace/local-inspection.mjs");
|
||||
|
||||
const FIXTURES = path.join(here, "..", "fixtures", "local-inspect");
|
||||
const promptFor = (text) => JSON.stringify({ 3: { class_type: "KSampler", inputs: { seed: 7, steps: 20, cfg: 8, sampler_name: "euler", scheduler: "normal", model: ["4", 0], positive: ["6", 0], negative: ["7", 0] } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, 6: { class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } }, 7: { class_type: "CLIPTextEncode", inputs: { text: "blurry", clip: ["4", 1] } } });
|
||||
const MODIFIED = 1_700_000_000_000;
|
||||
|
||||
/** A File that records each slice and lets a test hold slice reads (`gate(index)` returns a promise). */
|
||||
class RecordingFile extends File {
|
||||
constructor(parts, name, { gate, ...options } = {}) {
|
||||
super(parts, name, { lastModified: MODIFIED, ...options });
|
||||
this.ranges = [];
|
||||
this.gate = gate;
|
||||
}
|
||||
slice(start, end) {
|
||||
const index = this.ranges.push([start, end]) - 1;
|
||||
const part = super.slice(start, end), gate = this.gate;
|
||||
return { size: part.size, arrayBuffer: async () => { if (gate) await gate(index); return part.arrayBuffer(); } };
|
||||
}
|
||||
}
|
||||
const pngFile = (name, text = "a red fox", options) => new RecordingFile([raw(ihdr(64, 48), idat(), tEXt("prompt", promptFor(text)), iend())], name, { type: "image/png", ...options });
|
||||
const fixture = (name, as = name) => new RecordingFile([fs.readFileSync(path.join(FIXTURES, name))], as);
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((resolve) => setImmediate(resolve)); };
|
||||
|
||||
function urls({ fail = false } = {}) {
|
||||
const created = [], revoked = [];
|
||||
return {
|
||||
created, revoked,
|
||||
createObjectURL(file) { if (fail) throw new Error("no object URLs"); const url = `blob:http://localhost/${created.length + 1}`; created.push({ url, file }); return url; },
|
||||
revokeObjectURL(url) { revoked.push(url); },
|
||||
};
|
||||
}
|
||||
function pageTarget() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, callback) { listeners.set(type, callback); },
|
||||
removeEventListener(type, callback) { if (listeners.get(type) === callback) listeners.delete(type); },
|
||||
fire(type) { listeners.get(type)?.({ type }); },
|
||||
};
|
||||
}
|
||||
function session(options = {}) {
|
||||
const parentScope = options.parentScope ?? createScope(), spies = options.urls ?? urls(), page = options.page ?? pageTarget();
|
||||
const value = m.createLocalSession({ parentScope, createObjectURL: spies.createObjectURL, revokeObjectURL: spies.revokeObjectURL,
|
||||
schedulerYield: () => Promise.resolve(), pageTarget: page, extractFacts: ComfyMetadata.extractGenerationFacts, ...options.overrides });
|
||||
return { session: value, parentScope, urls: spies, page };
|
||||
}
|
||||
/** Resolves with the first controller state `predicate` accepts. */
|
||||
function until(controller, predicate) {
|
||||
return new Promise((resolve) => {
|
||||
let stop = null, done = false;
|
||||
stop = controller.subscribe((state) => { if (!done && predicate(state)) { done = true; resolve(state); stop?.(); } });
|
||||
if (done) stop();
|
||||
});
|
||||
}
|
||||
const recorded = (controller) => until(controller, (state) => state.record !== null || state.error);
|
||||
|
||||
test("contract", () => {
|
||||
assert.equal(typeof m.createLocalSession, "function", "createLocalSession is exported");
|
||||
assert.equal(typeof m.createYield, "function", "createYield is exported");
|
||||
assert.deepEqual(Object.keys(m.LOCAL_SESSION_MESSAGES).sort(), ["containerOnly", "directory", "multiple", "previewFailed", "unreadable", "unsupported"]);
|
||||
const { session: value } = session();
|
||||
for (const name of ["select", "retry", "subscribe", "dispose"]) assert.equal(typeof value[name], "function", name);
|
||||
assert.equal(value.state.phase, "empty");
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(typeof value.controller.select, "function");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("replacement synchronously clears old capabilities", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
const states = [];
|
||||
value.subscribe((state) => states.push({ ...state, revokedAtPublish: spies.revoked.length }));
|
||||
// Two different Files with the same name, size and time are two selections.
|
||||
const a = pngFile("same.png", "first fox"), b = pngFile("same.png", "other fox");
|
||||
assert.equal(a.size, b.size);
|
||||
assert.equal(value.select([a]), "accepted");
|
||||
assert.equal(value.state.phase, "classifying");
|
||||
const first = await recorded(value.controller);
|
||||
assert.equal(first.record.prompts.positive.text, "first fox");
|
||||
const entryA = value.entry, urlA = spies.created[0].url;
|
||||
assert.deepEqual([entryA.filename, entryA.is_video], ["same.png", false]);
|
||||
assert.deepEqual(value.state.preview, { url: urlA, media: "image" });
|
||||
assert.equal(localFileAdapter.originalDownload(entryA).url, urlA);
|
||||
|
||||
const before = states.length;
|
||||
assert.equal(value.select([b]), "accepted");
|
||||
// Synchronously: no record, no entry, no preview, the old URL revoked once and the old handle foreign.
|
||||
assert.equal(value.controller.state.record, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.deepEqual(spies.revoked, [urlA]);
|
||||
for (const method of ["originalDownload", "mediaUrl", "key"]) assert.throws(() => localFileAdapter[method](entryA), TypeError, method);
|
||||
assert.throws(() => localFileAdapter.originalDownload({ ...entryA }), TypeError, "a copy of a released handle is foreign too");
|
||||
// The preview left the published state before its URL was revoked.
|
||||
assert.deepEqual([states[before].phase, states[before].preview, states[before].revokedAtPublish], ["classifying", null, 0]);
|
||||
const second = await recorded(value.controller);
|
||||
assert.equal(second.record.prompts.positive.text, "other fox");
|
||||
assert.notEqual(value.entry.key, entryA.key);
|
||||
assert.equal(spies.revoked.length, 1);
|
||||
|
||||
// A read still in flight when the next file arrives never publishes.
|
||||
const held = deferred(), published = [];
|
||||
value.controller.subscribe((state) => { if (state.record) published.push(state.record.identity.key); });
|
||||
const slow = pngFile("slow.png", "late fox", { gate: (index) => (index < 2 ? Promise.resolve() : held.promise) });
|
||||
value.select([slow]);
|
||||
await until(value.controller, (state) => state.loading);
|
||||
const slowKey = value.entry.key;
|
||||
assert.equal(value.select([pngFile("next.png", "current fox")]), "accepted");
|
||||
held.resolve();
|
||||
const current = await recorded(value.controller);
|
||||
await settle();
|
||||
assert.equal(current.record.prompts.positive.text, "current fox");
|
||||
assert.ok(!published.includes(slowKey), "the replaced read never published");
|
||||
assert.equal(value.controller.state.record.prompts.positive.text, "current fox");
|
||||
value.dispose();
|
||||
assert.equal(new Set(spies.revoked).size, spies.revoked.length, "each URL revoked once");
|
||||
assert.deepEqual(spies.revoked.sort(), spies.created.map((item) => item.url).sort());
|
||||
});
|
||||
|
||||
test("cancel and invalid drops keep the previous selection", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
value.select([pngFile("kept.png", "kept fox")]);
|
||||
await recorded(value.controller);
|
||||
const entry = value.entry, state = value.state;
|
||||
const directory = new File([], "folder");
|
||||
const drops = [
|
||||
[[], "rejected-empty"], [{ files: [], items: [] }, "rejected-empty"], [null, "ignored"], [undefined, "ignored"],
|
||||
[[pngFile("one.png"), pngFile("two.png")], "rejected-multiple"],
|
||||
[{ files: [pngFile("one.png"), pngFile("two.png")], items: [{ kind: "file" }, { kind: "file" }] }, "rejected-multiple"],
|
||||
[{ files: [directory], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: true, isFile: false }) }] }, "rejected-directory"],
|
||||
[{ files: [], items: [{ kind: "string" }] }, "rejected-empty"],
|
||||
];
|
||||
for (const [input, outcome] of drops) {
|
||||
assert.equal(value.select(input), outcome, JSON.stringify(input));
|
||||
assert.equal(value.entry, entry);
|
||||
assert.equal(value.state, state);
|
||||
assert.equal(value.controller.state.record.prompts.positive.text, "kept fox");
|
||||
}
|
||||
assert.equal(spies.created.length, 1);
|
||||
assert.deepEqual(spies.revoked, []);
|
||||
// One File in a DataTransfer whose entry is a file is accepted.
|
||||
assert.equal(value.select({ files: [pngFile("dropped.png", "dropped fox")], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: false, isFile: true }) }] }), "accepted");
|
||||
assert.equal((await recorded(value.controller)).record.prompts.positive.text, "dropped fox");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("unclassified input creates no object URL", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
const text = (body, name) => new RecordingFile([body], name);
|
||||
for (const file of [text("plain text", "a.png"), text("<!doctype html><p>hi", "a.mp4"), text('<svg xmlns="http://www.w3.org/2000/svg"/>', "a.png"),
|
||||
text("#EXTM3U\n#EXT-X-VERSION:3\n", "a.mp4"), new RecordingFile([], "empty.png")]) {
|
||||
assert.equal(value.select([file]), "accepted");
|
||||
await settle();
|
||||
assert.equal(value.state.phase, "unsupported", file.name);
|
||||
assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.unsupported);
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.controller.state.record, null);
|
||||
assert.ok(file.ranges.every(([start, end]) => start === 0 && end <= 32), "only the prefix is read");
|
||||
}
|
||||
// A file that cannot be read after choosing (removed or denied) is not an unsupported type: when the browser
|
||||
// declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
|
||||
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
||||
assert.equal(value.select([gone]), "accepted");
|
||||
await settle();
|
||||
const unreadable = value.controller.state;
|
||||
assert.ok(unreadable.record, "the unreadable record is published");
|
||||
assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
|
||||
assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
|
||||
assert.equal(value.entry.filename, "gone.png");
|
||||
assert.equal(unreadable.record.metadata.status, "unreadable");
|
||||
assert.equal(unreadable.record.download, null);
|
||||
assert.equal(spies.created.length, 0);
|
||||
// A prefix read that fails for another reason than the browser's read failure is not claimed as unreadable.
|
||||
const broken = pngFile("broken.png", "fox", { gate: () => Promise.reject(new TypeError("not a read failure")) });
|
||||
assert.equal(value.select([broken]), "accepted");
|
||||
await settle();
|
||||
assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]);
|
||||
// A read failure for a selection already replaced publishes nothing for it.
|
||||
const slow = deferred();
|
||||
const replaced = pngFile("replaced.png", "fox", { gate: () => slow.promise });
|
||||
value.select([replaced]);
|
||||
await settle();
|
||||
value.select([new RecordingFile([new Uint8Array(4)], "next.txt")]);
|
||||
slow.reject(new DOMException("The file was removed", "NotReadableError"));
|
||||
await settle();
|
||||
assert.deepEqual([value.state.phase, value.entry], ["unsupported", null]);
|
||||
// An unreadable file the browser declares no supported type for stays unsupported.
|
||||
const unknown = new RecordingFile([new Uint8Array(8)], "gone.bin", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
||||
assert.equal(value.select([unknown]), "accepted");
|
||||
await settle();
|
||||
assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]);
|
||||
assert.equal(spies.created.length, 0);
|
||||
// A real MP4 named .png is a video by its bytes.
|
||||
value.select([fixture("core-faststart.mp4", "clip.png")]);
|
||||
await settle();
|
||||
assert.equal(value.state.phase, "ready");
|
||||
assert.equal(value.state.preview.media, "video");
|
||||
assert.equal(value.entry.is_video, true);
|
||||
assert.equal(spies.created.length, 1);
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("video record publishes even if preview fails", async () => {
|
||||
const { session: value } = session({ urls: urls({ fail: true }) });
|
||||
value.select([fixture("core-tags.webm")]);
|
||||
const state = await recorded(value.controller);
|
||||
assert.equal(value.state.phase, "ready");
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.previewFailed);
|
||||
assert.equal(value.entry.is_video, true);
|
||||
assert.equal(state.error, false);
|
||||
assert.equal(state.record.file.media_type, "video");
|
||||
assert.equal(state.record.metadata.status, "available");
|
||||
assert.equal(state.record.generation.seed.value, "7");
|
||||
assert.equal(state.record.download, null, "no original without an object URL");
|
||||
value.dispose();
|
||||
// AVI keeps its preview and says its metadata is not read.
|
||||
const avi = session();
|
||||
avi.session.select([new RecordingFile([Buffer.from("RIFF"), new Uint8Array(4), Buffer.from("AVI LIST"), new Uint8Array(64)], "clip.avi")]);
|
||||
await recorded(avi.session.controller);
|
||||
assert.equal(avi.session.state.message, m.LOCAL_SESSION_MESSAGES.containerOnly);
|
||||
assert.equal(avi.session.state.preview.media, "video");
|
||||
avi.session.dispose();
|
||||
});
|
||||
|
||||
test("disposal settles a pending fallback yield", async () => {
|
||||
const timers = new Map(), cleared = [];
|
||||
let serial = 0;
|
||||
const setTimer = (callback, delay) => { assert.equal(delay, 0); timers.set(++serial, callback); return serial; };
|
||||
const clearTimer = (token) => { cleared.push(token); timers.delete(token); };
|
||||
// The yield itself: a timer that rejects with AbortError on abort and removes its listener.
|
||||
const yieldControl = m.createYield({ schedulerYield: null, setTimer, clearTimer });
|
||||
const controller = new AbortController();
|
||||
const pending = yieldControl(controller.signal);
|
||||
assert.equal(timers.size, 1);
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: "AbortError" });
|
||||
assert.deepEqual(cleared, [1], "the timer is cleared on abort (the once listener is gone with it)");
|
||||
// A turn that completes removes its abort listener, so a long-lived signal does not collect them.
|
||||
const kept = new AbortController(), removed = [];
|
||||
const remove = kept.signal.removeEventListener.bind(kept.signal);
|
||||
kept.signal.removeEventListener = (type, callback) => { removed.push(type); remove(type, callback); };
|
||||
const ok = yieldControl(kept.signal);
|
||||
timers.get(2)();
|
||||
await ok;
|
||||
assert.deepEqual(removed, ["abort"]);
|
||||
await assert.rejects(yieldControl(AbortSignal.abort()), { name: "AbortError" });
|
||||
// The session: a selection waiting on the fallback yield is settled by dispose and does no work.
|
||||
timers.clear(); cleared.length = 0;
|
||||
const { session: value, urls: spies } = session({ overrides: { schedulerYield: null, setTimer, clearTimer } });
|
||||
const file = pngFile("a.png");
|
||||
value.select([file]);
|
||||
assert.equal(timers.size, 1);
|
||||
const [token] = timers.keys();
|
||||
value.dispose();
|
||||
assert.deepEqual(cleared, [token]);
|
||||
await settle();
|
||||
assert.deepEqual(file.ranges, []);
|
||||
assert.equal(spies.created.length, 0);
|
||||
});
|
||||
|
||||
test("old scheduler.yield continuation is fenced", async () => {
|
||||
const turns = [];
|
||||
const { session: value, urls: spies } = session({ overrides: { schedulerYield: () => { const turn = deferred(); turns.push(turn); return turn.promise; } } });
|
||||
const a = pngFile("a.png", "old fox"), b = pngFile("b.png", "new fox");
|
||||
value.select([a]);
|
||||
value.select([b]);
|
||||
assert.equal(turns.length, 2);
|
||||
turns[0].resolve();
|
||||
await settle();
|
||||
assert.deepEqual(a.ranges, [], "the replaced selection never reads");
|
||||
assert.equal(spies.created.length, 0);
|
||||
assert.equal(value.state.phase, "classifying");
|
||||
turns[1].resolve();
|
||||
const state = await recorded(value.controller);
|
||||
assert.equal(state.record.prompts.positive.text, "new fox");
|
||||
assert.deepEqual(spies.created.map((item) => item.file), [b]);
|
||||
// A continuation that resumes after dispose is fenced too.
|
||||
value.select([pngFile("c.png")]);
|
||||
const late = turns.at(-1);
|
||||
value.dispose();
|
||||
late.resolve();
|
||||
await settle();
|
||||
assert.equal(spies.created.length, 1);
|
||||
});
|
||||
|
||||
test("retry rereads identically", async () => {
|
||||
const { session: value } = session();
|
||||
const file = pngFile("a.png", "retry fox");
|
||||
value.select([file]);
|
||||
const first = await recorded(value.controller);
|
||||
const classified = 1, reads = file.ranges.slice(classified);
|
||||
assert.ok(reads.length > 1);
|
||||
const key = value.entry.key;
|
||||
await value.retry();
|
||||
const again = value.controller.state;
|
||||
assert.deepEqual(file.ranges.slice(classified + reads.length), reads, "the same slice ranges");
|
||||
assert.equal(again.record.identity.key, key);
|
||||
assert.equal(again.record.identity.key, first.record.identity.key);
|
||||
assert.equal(again.record.prompts.positive.text, "retry fox");
|
||||
value.dispose();
|
||||
assert.equal(value.retry(), undefined, "a disposed session never reads");
|
||||
assert.equal(file.ranges.length, classified + 2 * reads.length);
|
||||
});
|
||||
|
||||
test("already disposed parent does zero work", () => {
|
||||
const parentScope = createScope();
|
||||
parentScope.dispose();
|
||||
const fail = (name) => () => assert.fail(`${name} called`);
|
||||
const page = { addEventListener: fail("addEventListener"), removeEventListener: fail("removeEventListener") };
|
||||
const value = m.createLocalSession({ parentScope, createObjectURL: fail("createObjectURL"), revokeObjectURL: fail("revokeObjectURL"),
|
||||
schedulerYield: fail("schedulerYield"), setTimer: fail("setTimer"), clearTimer: fail("clearTimer"), pageTarget: page });
|
||||
const file = pngFile("a.png");
|
||||
assert.equal(value.select([file]), "ignored");
|
||||
assert.equal(value.retry(), undefined);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.state.phase, "empty");
|
||||
const unsubscribe = value.subscribe(fail("listener"));
|
||||
unsubscribe();
|
||||
value.dispose(); value.dispose();
|
||||
assert.deepEqual(file.ranges, []);
|
||||
});
|
||||
|
||||
test("dispose, parent abort and page leave each release the URL once", async () => {
|
||||
// dispose: the preview is cleared for subscribers before its URL is revoked.
|
||||
const first = session(), seen = [];
|
||||
first.session.select([pngFile("a.png")]);
|
||||
await recorded(first.session.controller);
|
||||
first.session.subscribe((state) => seen.push([state.phase, state.preview, first.urls.revoked.length]));
|
||||
first.session.dispose(); first.session.dispose();
|
||||
assert.deepEqual(seen.at(-1), ["empty", null, 0]);
|
||||
assert.deepEqual(first.urls.revoked, [first.urls.created[0].url]);
|
||||
assert.equal(first.session.select([pngFile("b.png")]), "ignored");
|
||||
assert.equal(first.page.listeners.size, 0, "the page listener is removed");
|
||||
// Parent abort disposes.
|
||||
const second = session();
|
||||
second.session.select([pngFile("a.png")]);
|
||||
await recorded(second.session.controller);
|
||||
second.parentScope.dispose();
|
||||
assert.deepEqual(second.urls.revoked, [second.urls.created[0].url]);
|
||||
assert.equal(second.session.controller.state.record, null);
|
||||
assert.equal(second.page.listeners.size, 0);
|
||||
// pagehide releases the selection; a page restored from the back/forward cache can choose again.
|
||||
const third = session();
|
||||
third.session.select([pngFile("a.png")]);
|
||||
await recorded(third.session.controller);
|
||||
const entry = third.session.entry;
|
||||
third.page.fire("pagehide");
|
||||
assert.deepEqual(third.urls.revoked, [third.urls.created[0].url]);
|
||||
assert.equal(third.session.entry, null);
|
||||
assert.equal(third.session.state.phase, "empty");
|
||||
assert.equal(third.session.controller.state.record, null);
|
||||
assert.throws(() => localFileAdapter.originalDownload(entry), TypeError);
|
||||
third.page.fire("pagehide");
|
||||
assert.equal(third.urls.revoked.length, 1);
|
||||
assert.equal(third.session.select([pngFile("b.png", "after return")]), "accepted");
|
||||
assert.equal((await recorded(third.session.controller)).record.prompts.positive.text, "after return");
|
||||
third.session.dispose();
|
||||
assert.equal(third.urls.revoked.length, 2);
|
||||
// A page that owns page leave passes no pageTarget and ends the selection with clear(), as pagehide would.
|
||||
const owned = session({ overrides: { pageTarget: null } });
|
||||
owned.session.select([pngFile("a.png")]);
|
||||
await recorded(owned.session.controller);
|
||||
assert.equal(owned.page.listeners.size, 0, "no pagehide listener of its own");
|
||||
owned.session.clear();
|
||||
assert.deepEqual(owned.urls.revoked, [owned.urls.created[0].url]);
|
||||
assert.equal(owned.session.entry, null);
|
||||
assert.equal(owned.session.state.phase, "empty");
|
||||
owned.session.clear();
|
||||
assert.equal(owned.urls.revoked.length, 1, "clear is idempotent");
|
||||
assert.equal(owned.session.select([pngFile("b.png")]), "accepted");
|
||||
await recorded(owned.session.controller);
|
||||
owned.session.dispose();
|
||||
owned.session.clear();
|
||||
assert.equal(owned.urls.revoked.length, 2);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createScope} from '../../web/js/shared/toolkit/core.js';
|
||||
import gallerySettings from '../../web/js/gallery-settings.js';
|
||||
import {mountedDom} from './helpers/mounted-dom.mjs';
|
||||
|
||||
const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
@@ -15,7 +16,7 @@ test('disposed media viewer touches no DOM observers or requests', () => {
|
||||
const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden,
|
||||
onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')});
|
||||
if (!aborted) view.dispose();
|
||||
view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose();
|
||||
view.open(forbidden); view.stopSlideshow?.(); view.close(); view.dispose(); view.dispose(); parentScope.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,44 +34,6 @@ test('preferences handle denied malformed storage without mutation', () => {
|
||||
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
|
||||
});
|
||||
|
||||
function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
function occurrenceMedia(node,ordinal,key) {
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;
|
||||
@@ -247,3 +210,84 @@ test('viewer titles a linked card "<title> · <filename>" before any inspection
|
||||
assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
|
||||
} finally { other.dispose(); scope.dispose(); g.restore(); }
|
||||
});
|
||||
|
||||
// PR L2 Task 4(d): an adapter's own media URL (a session-owned blob:) is used exactly, never cache-busted.
|
||||
async function localHandles() {
|
||||
const {createLocalFileHandle} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const image = createLocalFileHandle({file:new File([new Uint8Array(8)], 'a.png'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000001', classification:{media:'image', kind:'png'}});
|
||||
const video = createLocalFileHandle({file:new File([new Uint8Array(8)], 'b.mp4'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000002', classification:{media:'video', kind:'iso-bmff'}});
|
||||
return {image, video};
|
||||
}
|
||||
function withViewerGlobals(f) {
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame, GallerySettings:globalThis.GallerySettings};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}, GallerySettings:gallerySettings});
|
||||
f.win.localStorage = {getItem:() => null, setItem:() => assert.fail('read-only preferences')};
|
||||
return () => Object.assign(globalThis, globals);
|
||||
}
|
||||
|
||||
test('viewer uses adapter.mediaUrl for image and video', async () => {
|
||||
const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const {image, video} = await localHandles();
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), instances = [];
|
||||
f.win.Viewer = class { constructor(node) { this.node = node; instances.push(this); } destroy() {} resize() {} };
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[image, video],selectedKey:image.key});
|
||||
const img = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(img.src, image.url, 'the blob URL is used exactly');
|
||||
assert.doesNotMatch(img.src, /\?/);
|
||||
assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
|
||||
assert.equal(download.href, image.url);assert.equal(download.download, 'a.png');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
const node = f.walk(f.container,n => n.tagName === 'video')[0];
|
||||
assert.equal(node.src, video.url);assert.doesNotMatch(node.src, /\?/);
|
||||
assert.equal(f.walk(f.container,n => n.tagName === 'img').length, 0);
|
||||
view.dispose();
|
||||
assert.equal(node.attributes.src, undefined);assert.equal(node.loaded, true, 'closing releases the video source');
|
||||
} finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
});
|
||||
|
||||
test('retry media keeps the blob URL exact', async () => {
|
||||
const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const {image} = await localHandles();
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope();
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[image],selectedKey:image.key});
|
||||
const first = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(first.src, image.url);
|
||||
first.fire('error');
|
||||
const failure = f.walk(f.container,n => n.className === 'media-failure')[0];
|
||||
assert.ok(!failure.hidden, 'the media failure shows');
|
||||
f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click');
|
||||
const second = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.notEqual(second, first, 'retry rebuilds the media element');
|
||||
assert.equal(second.src, image.url, 'retry reuses the exact blob URL');
|
||||
} finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
});
|
||||
|
||||
test('server adapters still cache-bust the original on open and retry', async () => {
|
||||
const {rootedFileAdapter, createInspectionController} = await import('../../web/js/workspace/inspection-state.mjs');
|
||||
const {reportedLocationAdapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const scope = 'a'.repeat(64);
|
||||
const rooted = {root_index:0,path:'folder/a.png',filename:'a.png',root_scope:scope,is_video:false,url:`/prompt_manager/images/serve/folder/a.png?root=0&root_scope=${scope}`};
|
||||
for (const [adapter, item, key, path] of [[rootedFileAdapter, rooted, '0:folder/a.png', `/prompt_manager/images/inspect/folder/a.png?root=0&root_scope=${scope}`],
|
||||
[reportedLocationAdapter, occurrenceMedia('1',1,'c'), 'c'.repeat(64), occurrenceMedia('1',1,'c').inspection_url]]) {
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), paths = [];
|
||||
const controller = createInspectionController({adapter,api:{get(url) {paths.push(url);return new Promise(() => {});}}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onSelect:entry => controller.select(entry),sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[item],selectedKey:key});
|
||||
assert.deepEqual(paths, [path], 'the same api.get path');
|
||||
const first = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.match(first.src, /_pm_viewer=/);
|
||||
const url = new URL(first.src);url.searchParams.delete('_pm_viewer');
|
||||
assert.equal(`${url.pathname}${url.search}`, item.url);
|
||||
first.fire('error');
|
||||
f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click');
|
||||
const second = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.match(second.src, /_pm_viewer=/);assert.notEqual(second.src, first.src, 'retry busts the cache again');
|
||||
} finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,8 +23,21 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
|
||||
assert.equal(routeTitle(route), route.label);
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
|
||||
});
|
||||
|
||||
test('inspect is the native Inspect a file page with the concept description', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff } = await source;
|
||||
const inspect = resolveRoute('#/inspect');
|
||||
assert.equal(inspect.label, 'Inspect a file');
|
||||
assert.equal(routeTitle(inspect), 'Inspect a file');
|
||||
// The concept's heading description (app.js renderMetadata 743).
|
||||
assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
|
||||
assert.equal(isHandoff(inspect), false);
|
||||
// Overview keeps its card for the legacy metadata page, which stays working.
|
||||
assert.equal(inspect.href, '/prompt_manager/gallery.html');
|
||||
assert.equal(inspect.action, 'Open existing metadata inspector');
|
||||
});
|
||||
|
||||
test('a job of the Activity page is its own deep link', async () => {
|
||||
|
||||
Vendored
+1
-1
@@ -22,7 +22,7 @@ test('execution script discovers only the new matrix and preserves all 78 origin
|
||||
'test', '--config', config, '--list'], { cwd: root, encoding: 'utf8',
|
||||
env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } });
|
||||
const stock = list('tools/vendor/comfyui.config.mjs');
|
||||
assert.match(stock, /Total: 80 tests in 7 files/);
|
||||
assert.match(stock, /Total: 82 tests in 8 files/); // + local Inspect host case (inspect-sender.spec.mjs, 2 projects)
|
||||
assert.doesNotMatch(stock, /execution-history/);
|
||||
assert.doesNotMatch(stock, /execution-outputs/);
|
||||
const executions = list('tools/vendor/playwright.execution.config.mjs');
|
||||
|
||||
Reference in New Issue
Block a user