import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
const details = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
// The concept panel names no source (D20); its cover is the selected file's own rooted URL, root 0 output, 1 input.
const SOURCE_ROOTS = { output: 0, input: 1 };
const expectPanelSource = (page, source) => expect(details(page).locator('img.detail-cover')).toHaveAttribute('src', new RegExp(`[?&]root=${SOURCE_ROOTS[source]}(&|$)`));
// The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view".
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
const caption = page => page.locator('.section-caption');
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true });
// D44: the Filters button reads "Filters · N" while N Refine filters are active.
const filtersButton = page => page.locator('#workspace-main search.toolbar').getByRole('button', { name: /^Filters(?: · \d+)?$/ });
async function refine(page, values) {
await filtersButton(page).click();
const dialog = refineDialog(page);
for (const [label, value] of Object.entries(values)) {
const control = dialog.getByLabel(label, { exact: true });
if (label === 'Tags') await control.fill(value);
else await control.selectOption(value);
}
await dialog.getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(dialog).toBeHidden();
}
async function rescan(page) {
await page.getByRole('button', { name: 'Import', exact: true }).click();
await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.imageErrors = [];
page.on('pageerror', error => page.imageErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.imageErrors).toEqual([]);
});
test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }, testInfo) => {
await page.setViewportSize({width:1600,height:1100});
async function capture(name) {
await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(image=>image.decode().catch(()=>{})));});
const path=testInfo.outputPath(`${name}.png`);await page.screenshot({path});
await testInfo.attach(name,{path,contentType:'image/png'});
}
const snapshot = await (await request.get('/prompt_manager/stats')).json();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey)))
.toEqual(['0:shared/same.png', '1:shared/same.png']);
const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src));
expect(new Set(originals).size).toBe(2);
await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expectPanelSource(page, 'input');
await capture('after-images-source-collision-input-1600');
await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expectPanelSource(page, 'output');
await capture('after-images-source-collision-output-1600');
await page.setViewportSize({width:390,height:844});
for (const [index,source] of [[1,'input'],[0,'output']]) {
await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click();
await expectPanelSource(page, source);
await details(page).scrollIntoViewIfNeeded();
await capture(`after-images-source-collision-${source}-390`);
}
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats);
});
test('workspace defaults to the native Images collection without a hash route', async ({ page }) => {
await page.goto('/prompt_manager/workspace');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page');
});
test('image selection is shown on the selected card', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// D19: Info selects into the panel; as in the concept, the card carries `.selected` and Info has no pressed state.
const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true });
await selected.click();
await expect(cards(page).last()).toHaveClass(/\bselected\b/);
await expect(cards(page).first()).not.toHaveClass(/\bselected\b/);
await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0);
});
test('the side panel cover is the concept plain image, never a preview control', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expectPanelSource(page, 'input');
// D19/D20: the card image opens the viewer; the panel cover is decoration (`alt=""`) and not focusable.
await expect(details(page).getByRole('button', { name: 'Open full preview', exact: true })).toHaveCount(0);
await expect(details(page).locator('img.detail-cover')).toHaveAttribute('alt', '');
await details(page).locator('img.detail-cover').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
let reads = 0;
await page.route(imagePages, async route => {
const current = reads++;
await route.fulfill({ json: {
success: true, images: [real.images[current]], total: 2,
has_more: current === 0, next_cursor: current === 0 ? 'next-short-batch' : null,
} });
});
await page.setViewportSize({ width: 1440, height: 1200 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(reads).toBe(2);
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden();
});
for (const embedded of [false, true]) {
for (const close of ['Close', 'Escape', 'parent abort']) {
test(`viewer closure resumes native continuation after a short pending page: ${embedded ? 'embedded' : 'Images'} ${close}`, async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
const data = await (await request.get('/prompt_manager/images/output?limit=200')).json();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.route('**/prompt_manager/workspace', async route => {
const response = await route.fetch();
const html = (await response.text()).replace('', '');
await route.fulfill({ response, body: html });
});
await page.goto('/prompt_manager/workspace');
await page.evaluate(async ({ images, embedded }) => {
const { mountImages } = await import('/prompt_manager/js/workspace/images.mjs');
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
window.ApiClient = { resolveUrl: value => value };
const fixture = window.continuationFixture = { reads: [], scope: createScope() };
const api = { get: async (url, options) => (await fetch(url, options)).json() };
const fetchPage = ({ parameters }) => {
fixture.reads.push(parameters.get('cursor'));
if (fixture.reads.length === 1) return { images: images.slice(0, 40), total: 80, has_more: true, next_cursor: 'next-0' };
if (fixture.reads.length === 2) return new Promise(resolve => {
fixture.finishPage = () => resolve({ images: [], total: 80, has_more: true, next_cursor: 'next-1' });
});
return { images: images.slice(40, 80), total: 80, has_more: false, next_cursor: null };
};
mountImages(document.querySelector('#workspace-main'), api, fixture.scope, { embedded, fetchPage });
}, { images: data.images, embedded });
await expect(cards(page)).toHaveCount(40);
expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]);
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']);
const key = await cards(page).last().getAttribute('data-image-key');
const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Open / });
await preview.click();
const viewer = page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await page.evaluate(() => window.continuationFixture.finishPage());
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false');
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.infinite-continuation').getBoundingClientRect().top <= innerHeight })))
.toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true });
if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose());
else if (close === 'Close') await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
else await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
if (close !== 'parent abort') {
await expect(preview).toBeFocused();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
// An embedded collection has no caption (D47); its loaded cards show the continuation instead.
if (embedded) { await expect(cards(page)).toHaveCount(80); await expect(caption(page)).toHaveCount(0); }
else await expect(caption(page)).toContainText('80 of 80');
}
await page.evaluate(() => window.continuationFixture.scope.dispose());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const reads = await page.evaluate(() => window.continuationFixture.reads);
expect(reads).toEqual(close === 'parent abort' ? [null, 'next-0'] : [null, 'next-0', 'next-1']);
await testInfo.attach('native-continuation-reads', { body: JSON.stringify({ embedded, close, reads }), contentType: 'application/json' });
});
}
}
test('source, folder and media filters operate on real backend records', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await refine(page, { Source: '1' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await refine(page, { Source: 'all', Folder: 'bulk' });
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//);
await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i);
await refine(page, { Media: 'image' });
await expect(cards(page)).toHaveCount(3);
await refine(page, { Folder: '' });
await expect(cards(page)).toHaveCount(5);
});
test('filename search finds records beyond the initial batch and sort orders them', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await search(page).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await search(page).fill('0099');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png');
await search(page).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await sortBy(page).selectOption('oldest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('newest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page)).toHaveCount(2);
await expectPanelSource(page, 'input');
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
}));
expect(boxes[0].right <= boxes[1].x || boxes[1].right <= boxes[0].x ||
boxes[0].bottom <= boxes[1].y || boxes[1].bottom <= boxes[0].y).toBe(true);
}
});
test('returning to Images restores the filtered stream, layout, selection and scroll context', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
const box = search(page);
await refine(page, { Source: '0' });
await box.fill('00');
await expect(caption(page)).toContainText('40 of 100 images');
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(caption(page)).toContainText('80 of 100 images');
await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
await cards(page).nth(30).scrollIntoViewIfNeeded();
const previousScroll = await page.evaluate(() => window.scrollY);
expect(previousScroll).toBeGreaterThan(500);
// Hash navigation preserves the scroll position being tested; clicking the fixed
// navigation can otherwise scroll a non-sticky mobile sidebar into view first.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(box).toHaveValue('00');
await expect(page.locator('.filter-row span.chip')).toHaveText(/ · All output folders$/);
await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(caption(page)).toContainText('80 of 100 images');
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
// Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. The restored Source
// still counts (D44).
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0');
await page.keyboard.press('Escape');
await expect(refineDialog(page)).toBeHidden();
});
test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => {
// Exercise the keyboard fallback independently from IntersectionObserver scrolling.
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; });
await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selected = await cards(page).first().getAttribute('data-image-key');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(80);
await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes');
await expect(cards(page).first()).toHaveAttribute('data-image-key', selected);
await expect(details(page)).toBeVisible();
// The concept (app.js loadMoreImages) focuses the first appended image after a Load more button press.
await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(102);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102);
await expect(more).toBeHidden();
await expect(page.locator('article[aria-posinset="81"] .image-open')).toBeFocused();
await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0);
});
test('scrolling to the sentinel automatically appends the next backend batch', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
await first.evaluate(element => { element.dataset.preserved = 'scroll'; });
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();
await expect(cards(page)).toHaveCount(80);
await expect(first).toHaveAttribute('data-preserved', 'scroll');
});
test('large continuous collections bound the mounted cards and restore earlier images on backscroll', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
const firstKey = await first.getAttribute('data-image-key');
await first.getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
}
await expect(more).toBeHidden();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size)
.toBe(await cards(page).count());
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toBeVisible();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await page.locator(`article[data-image-key="${firstKey}"]`)
.getByRole('button', { name: /^Open / }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
});
test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label');
await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
}
await page.evaluate(() => window.scrollTo(0, 0));
const first = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(first).toBeVisible();
await first.getByRole('button', { name: firstName, exact: true }).focus();
await page.keyboard.press('End');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')
.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-posinset', '202');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-setsize', '202');
await expect(page.getByRole('list', { name: 'Image collection', exact: true })).toBeVisible();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('role', 'listitem');
await page.keyboard.press('Home');
await expect(first.getByRole('button', { name: firstName, exact: true })).toBeFocused();
await expect(first).toHaveAttribute('aria-posinset', '1');
await page.keyboard.press('ArrowRight');
await expect(page.locator('article[aria-posinset="2"]').getByRole('button', { name: /^Open / })).toBeFocused();
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(() => cards(page).count()).toBeLessThan(120);
});
test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/);
await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('.viewer-container')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('real video media has a usable card and native playback cleans up on close and navigation', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
// #249: a video with no preview yet still lists its preview URL; the server makes the frame on first view.
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.webp\?root=0/);
await page.goto(workspace);
await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(1);
const card = cards(page).first();
await expect(card).toHaveAttribute('data-image-key', '0:clip.webm');
await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible();
// The card shows the preview frame, never the video itself; D11 names the video's own extension.
await expect(card.locator('video')).toHaveCount(0);
await expect(card.locator('.media-art > .image-open > img')).toHaveCount(1);
await expect(card.locator('.media-info p')).toHaveText(/(?:^|· )WEBM$/);
await expect.poll(() => card.locator('img').evaluate(node => node.complete && node.naturalHeight > 0)).toBe(true);
expect(await card.locator('img').evaluate(node => node.getBoundingClientRect().height)).toBeGreaterThan(20);
await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
const video = page.locator('dialog[open] video');
await expect(video).toBeVisible();
await expect(video).toHaveJSProperty('controls', true);
await expect.poll(() => video.evaluate(element => element.readyState)).toBeGreaterThanOrEqual(1);
const playing = await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
return !element.paused;
});
expect(playing).toBe(true);
await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(page.locator('dialog video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
await expect(video).toBeVisible();
await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
});
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
});
test('collection viewer ignores delayed media callbacks and keeps inspection retries independent',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
await page.evaluate(()=>{const Native=window.Viewer;window.liveViewers=[];window.Viewer=class extends Native {constructor(...args){super(...args);window.liveViewers.push(this);}};});
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(preview.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();
await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release();
expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90);
await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click();
await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);
expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true);
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer leaving an image Viewer.js has not built yet releases it at once',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
await page.evaluate(()=>{window.unbuiltImage=document.querySelector('#viewer-stage > img');});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
// The left image is off the stage. Viewer.js has not built it, so the viewer is destroyed and the src released at
// once: its late load must never build Viewer.js into an image without a parent (getComputedStyle(null)).
release();
await expect.poll(()=>page.evaluate(()=>[window.unbuiltImage.isConnected,window.unbuiltImage.hasAttribute('src'),!!window.unbuiltImage.viewer])).toEqual([false,false,false]);
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer closed while its image finishes loading never builds Viewer.js afterwards',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
let held;const waiting=new Promise(resolve=>{held=resolve;});
// The image is held for the whole test: its late load is delivered below, at the moment Viewer.js cannot cancel.
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();return;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
// Viewer.js's own cancel removes its image listeners only while `image.complete` is false. An image that has
// finished downloading but whose load event is still queued is already complete: close in that state, then deliver it.
await page.evaluate(()=>{window.finishingImage=document.querySelector('#viewer-stage > img');Object.defineProperty(window.finishingImage,'complete',{get:()=>true});});
await page.keyboard.press('Escape');await expect(preview).toHaveCount(0);
const after=await page.evaluate(async()=>{
const image=window.finishingImage;image.dispatchEvent(new Event('load'));
// Viewer.js builds an inline viewer in a zero-delay timeout after its image loads; this runs after it.
await new Promise(resolve=>setTimeout(resolve));
return {src:image.hasAttribute('src'),built:!!image.parentNode?.querySelector('.viewer-container')};
});
expect(after).toEqual({src:false,built:false});
});
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
const preview=page.locator('dialog#viewer-dialog'),video=preview.locator('video');
await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true);
const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.');
await video.focus();const position=await preview.locator('.media-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.media-position')).toHaveText(position);
await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();});
await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false');
expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true);
await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(page.locator('#viewer-dialog')).toHaveCount(0);
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route(imagePages, route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(cards(page)).toHaveCount(2);
let release;
const pending = new Promise(resolve => { release = resolve; });
let arrived;
const received = new Promise(resolve => { arrived = resolve; });
await page.route(imagePages, async route => {
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
});
await search(page).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
release();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(cards(page)).toHaveCount(0);
await expect(details(page)).toHaveCount(0);
});
test('source and folder discovery failure stays visible and Import Rescan restores usable filters', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Sources unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Rescan from Import to retry.');
await rescan(page);
await expect(page.getByRole('alert')).toHaveCount(0);
await refine(page, { Source: '1' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await refine(page, { Folder: 'shared' });
await expect(cards(page)).toHaveCount(1);
});
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const filters = filtersButton(page);
await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
await refine(page, { Source: '0', Folder: 'bulk' });
const configured = page.waitForResponse(response => {
const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name';
});
await search(page).fill('png');
await refine(page, { Media: 'image' });await sortBy(page).selectOption('name');
const initial = await (await configured).json();await expect(caption(page)).toContainText('40 of 80');
let oldPage;
await page.route(imagePages, async route => {
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
const response = await route.fetch();oldPage = { route, data: await response.json() };
} else await route.continue();
});
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();await expect.poll(() => !!oldPage).toBe(true);
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
await page.route('**/prompt_manager/gallery/subfolders?*', route => { options.push(route); });
await viewer.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1);
await oldPage.route.fulfill({ json: oldPage.data });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
return { source, folder, filters, pages, inspections, options, mutations };
}
for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) {
test(`source-change recovery clears a ${name} root before discovery and safe retry`, async ({ page, request }, testInfo) => {
const recovery = await staleSourceRecovery(page, request, roots);
try {
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');
for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
// D44: recovery clears Source and Folder; the Media filter stays and still counts.
await expect(recovery.filters).toHaveText('Filters · 1');await expect(recovery.filters).toBeDisabled();
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeDisabled();
await expect(search(page)).toBeDisabled();
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change');
await expect(refineDialog(page)).toBeHidden();
await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } });
await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible();
expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0);
const retry = details(page).getByRole('button', { name: 'Refresh image collection', exact: true });await expect(retry).toBeEnabled();
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-discovery-failure.png') });
await retry.click();await expect.poll(() => recovery.options.length).toBe(2);
expect(new URL(recovery.options[1].request().url()).searchParams.has('root')).toBe(false);
const response = await recovery.options[1].fetch();await recovery.options[1].fulfill({ response });
await expect.poll(() => recovery.pages.length).toBe(1);
const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false);
expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name');
await expect(recovery.filters).toBeEnabled();await recovery.filters.click();await expect(recovery.source).toHaveValue('all');
await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);await page.keyboard.press('Escape');
await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0');
expect(recovery.mutations).toEqual([]);
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') });
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
}
test('source-change recovery ignores superseded discovery and route disposal', async ({ page, request }) => {
const recovery = await staleSourceRecovery(page, request, ['input', 'output']);
try {
expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2);
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(recovery.pages).toEqual([]);await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');await expect(recovery.filters).toBeDisabled();
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expectPanelSource(page, 'input');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await expect(search(page)).toBeVisible();
});
test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
let release;
let arrived;
let complete;
const pending = new Promise(resolve => { release = resolve; });
const received = new Promise(resolve => { arrived = resolve; });
const completed = new Promise(resolve => { complete = resolve; });
let delayed = false;
await page.route(imagePages, async route => {
if (delayed) return route.continue();
delayed = true;
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
complete();
});
const box = search(page);
await box.fill('same.png');
await received;
await box.fill('0001');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
release();
await completed;
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
});
test('selected layout stays blue with readable text on hover in either theme', async ({ page }) => {
await page.goto(workspace);
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
for (const theme of ['light', 'dark']) {
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await layout.click();
await layout.hover();
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await expect(layout).toHaveCSS('color', 'rgb(255, 255, 255)');
}
});
test('refresh releases the previous inspector cover source', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click();
// #249: a video's cover is its preview frame, not the video.
const cover = details(page).locator('img.detail-cover');
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
await expect(details(page).locator('video')).toHaveCount(0);
await cover.evaluate(element => { window.__oldInspectorCover = element; });
await rescan(page);
await expect.poll(() => page.evaluate(() => !window.__oldInspectorCover.isConnected)).toBe(true);
expect(await page.evaluate(() => window.__oldInspectorCover.getAttribute('src'))).toBeNull();
});
/** A listing that names no preview for its videos (the fallback a card and the cover keep for one). */
async function videosWithoutPreview(page) {
await page.route(url => url.pathname === '/prompt_manager/images/output', async route => {
const response = await route.fetch();
const body = await response.json();
if (Array.isArray(body.images)) body.images = body.images.map(image => image.is_video ? { ...image, thumbnail_url: null } : image);
await route.fulfill({ response, json: body });
});
}
test('a video listed without a preview keeps its own element, released on refresh', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await videosWithoutPreview(page);
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
await expect(card.locator('.media-art > .image-open > video')).toHaveCount(1);
await expect(card.locator('img')).toHaveCount(0);
await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click();
await expect(details(page).locator('video')).toBeVisible();
await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; });
await card.locator('video').evaluate(element => { window.__oldCardVideo = element; });
await rescan(page);
await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected && !window.__oldCardVideo.isConnected)).toBe(true);
expect(await page.evaluate(() => [window.__oldInspectorVideo.getAttribute('src'), window.__oldCardVideo.getAttribute('src')])).toEqual([null, null]);
});
test('Import Rescan rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({
status: 503, json: { success: false, error: 'Rescan unavailable' },
}), { times: 1 });
await rescan(page);
await expect(toast(page)).toHaveText('Rescan could not finish. Try again.');
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(cards(page)).toHaveCount(2);
await rescan(page);
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1);
await expect(page.getByRole('alert')).toBeHidden();
});
test('folder discovery offers ancestors that filter nested images', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
await page.getByRole('button', { name: 'Filters', exact: true }).click();
await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option[value="project"]')).toHaveCount(1);
// The concept's Folder default.
await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option').first()).toHaveText('All output folders');
await page.keyboard.press('Escape');
await refine(page, { Folder: 'project' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toContainText('new-output.png');
});
test('leaving during Rescan cancels it without replacing the next workspace', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/rescan', async route => {
await pending;
await route.fulfill({ json: { success: true } }).catch(() => {});
});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
await rescan(page);
await requested;
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
release();
await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible();
await expect(toast(page)).not.toHaveText(/^Rescan (finished|could not finish)/);
});
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.');
await expect(details(page)).toHaveCSS('width','310px');
await expect(page.locator('.gallery-layout')).toHaveCSS('column-gap','22px');
for (const label of ['Filter by category','Sort results','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible();
await expect(search(page)).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
await expect(page.getByRole('button',{name:'Group by positive prompt →',exact:true})).toBeVisible();
const geometry = await cards(page).evaluateAll(nodes=>nodes.slice(0,4).map(node=>{
const box=node.getBoundingClientRect(),media=node.querySelector('.image-open').getBoundingClientRect();
return {x:box.x,y:box.y,right:box.right,bottom:box.bottom,width:box.width,mediaWidth:media.width,mediaHeight:media.height};
}));
expect(geometry[1].x-geometry[0].right).toBeCloseTo(13,0);
expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0);
expect(geometry[3].x).toBeCloseTo(geometry[0].x,0);
// The concept's `.image-open img { aspect-ratio: 1.33 }`.
expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2);
expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y);
await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/);
const cover=details(page).locator('img.detail-cover');
await expect(cover).toBeVisible();await expect(cover).toHaveCSS('height','150px');
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='IMG')).toBe(true);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0);
await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0);
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
});
test('density changes and resize preserve identity focus and selection', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
await page.setViewportSize({width:1600,height:1100});await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
const first=cards(page).first();await first.evaluate(node=>{window.originalImageCard=node;});
await first.getByRole('button',{name:/^Inspect /}).click();
const key=await first.getAttribute('data-image-key');
let imageReads=0;page.on('request',request=>{if(request.url().includes('/images/output?')) imageReads++;});
for (const layout of ['Grid','List','Masonry']) for (const [density,columns] of [['comfortable',3],['large',2],['compact',4]]) {
await page.getByRole('button',{name:layout,exact:true}).click();
await page.getByRole('combobox',{name:'Thumbnail size',exact:true}).selectOption(density);
await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown');
await expect(cards(page).nth(layout==='List'?1:columns).getByRole('button',{name:/^Open /})).toBeFocused();
await expect(first).toHaveClass(/selected/);
expect(await first.evaluate(node=>node===window.originalImageCard)).toBe(true);
}
expect(imageReads).toBe(0);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('h1')).toHaveText('Overview');
await page.evaluate(()=>{location.hash='#/images';});await expect(page.getByRole('combobox',{name:'Thumbnail size',exact:true})).toHaveValue('compact');
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
await page.getByRole('button',{name:'Grid',exact:true}).click();
for (const [width,columns] of [[951,3],[950,3],[700,2],[390,2]]) {
await page.setViewportSize({width,height:844});
// 700px and 390px both lay out two columns, so a card at the row start alone is also true of the previous
// width's cards before WebKit runs the first frame after the resize; wait for the row to fit the new grid.
await expect.poll(()=>page.locator('.image-grid').evaluate((grid,columns)=>{
const row=[...grid.querySelectorAll(':scope > article[data-image-key]')];
return row[columns].offsetLeft===0&&row[columns-1].getBoundingClientRect().right<=grid.getBoundingClientRect().right+1;
},columns)).toBe(true);
await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown');
await expect(cards(page).nth(columns).getByRole('button',{name:/^Open /})).toBeFocused();
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
});
test('images toolbar and filter row match the concept controls', async ({ page, request }) => {
// Old → new: Search filenames → toolbar search; Source/Folder/Media → Filters; Refresh → Import → Rescan (D15).
await expect(await request.post('/prompt_manager/save', { data: { text: 'Dog landscape', category: 'Landscapes', tags: ['dogs'], rating: 5 } })).toBeOK();
const { roots } = await (await request.get('/prompt_manager/gallery/subfolders?mode=workspace')).json();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const main = page.locator('#workspace-main');
await expect(main.locator(':scope > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.');
await expect(main.locator(':scope > .heading .heading-actions > button')).toHaveText(['Import', 'New prompt']);
await expect(main.locator(':scope > .heading .heading-actions > button').nth(1)).toHaveClass(/ca-primary/);
await expect(page.getByRole('searchbox', { name: 'Search filenames' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
await expect(row.locator(':scope > label.small[for="image-size"]')).toHaveText('Thumbnail size');
await expect(row.locator(':scope > select#image-size option')).toHaveText(['Comfortable', 'Large', 'Compact']);
await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
await expect(row.locator(':scope > span.chip svg.icon')).toHaveCount(1);
await expect(row.locator(':scope > button.chip')).toHaveText('Group by positive prompt →');
await search(page).fill('cat');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
await search(page).fill('dogs');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
await sortBy(page).selectOption('rating');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('newest');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('');
await expect(cards(page)).toHaveCount(2);
await expect(filtersButton(page)).toHaveText('Filters');
await refine(page, { Source: '1', Folder: 'shared', Media: 'image' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`);
// D44: Source, Folder and Media each count once; the toolbar category is not a Refine filter.
await expect(filtersButton(page)).toHaveText('Filters · 3');
await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(0);
await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(0);
await expect(filtersButton(page)).toHaveText('Filters · 1');
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats');
await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click();
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue('');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeFocused();
const rescans = [];
page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); });
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await rescan(page);
await expect(cards(page)).toHaveCount(3);
await expect(caption(page)).toContainText('3 of 3 images');
expect(rescans).toEqual(['POST']);
await page.getByRole('button', { name: 'Import', exact: true }).click();
await importDialog(page).getByRole('button', { name: 'Open inspector', exact: true }).click();
await expect(page).toHaveURL(/#\/inspect$/);
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await row.locator(':scope > button.chip').click();
await expect(page).toHaveURL(/#\/library$/);
});
test('browser Back closes the open Images dialog and leaves the next page usable', async ({ page }) => {
await page.goto('/prompt_manager/workspace#/overview');
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
for (const [button, dialog] of [['Filters', refineDialog], ['Import', importDialog]]) {
await page.evaluate(() => { location.hash = '/images'; });
await expect(cards(page)).toHaveCount(2);
await page.getByRole('button', { name: button, exact: true }).click();
await expect(dialog(page)).toBeVisible();
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
await expect(cards(page)).toHaveCount(2);
await search(page).fill('cat');
await expect(cards(page)).toHaveCount(1);
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await page.evaluate(() => { location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
}
});
test('count line shows images and positive prompts', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
// D17's "Today" is the newest file's day: the page's clock stands at that file's mtime, so the fixture's day and
// the page's agree on either side of midnight.
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
await page.clock.setFixedTime(new Date(Math.max(...images.map(image => image.modified_time)) * 1000));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts');
await expect(caption(page).locator(':scope > span')).toHaveCount(2);
await expect(caption(page).locator(':scope > span').nth(1)).toHaveText(/^Today · [A-Z][a-z]+ \d{1,2}$/);
await sortBy(page).selectOption('name');
await expect(cards(page).first()).toHaveAttribute('data-image-key', /same\.png$/);
await expect(caption(page).locator(':scope > span').nth(1)).toBeHidden();
await search(page).fill('cat');
await expect(caption(page).locator('#result-count')).toHaveText('1 of 1 image · 1 positive prompt');
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
// A plain cursor page leaves positive_count out; the first page's count stays.
const cursorPages = [];
await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => {
const response = await route.fetch();
const json = await response.json();
cursorPages.push('positive_count' in json);
await route.fulfill({ response, json });
});
// A fresh document: a same-page visit would restore the earlier search.
await page.goto('about:blank');
await page.goto(workspace);
await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images · 2 positive prompts');
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(caption(page).locator('#result-count')).toHaveText('80 of 102 images · 2 positive prompts');
expect(cursorPages).toEqual([false]);
// A failed count read is null: the count line keeps only the images.
await page.unrouteAll({ behavior: 'wait' });
await page.route(imagePages, async route => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), positive_count: null } });
});
await page.goto('about:blank');
await page.goto(workspace);
await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images');
});
test('Load more toast reports the new total', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await expect(page.locator('.infinite-continuation p.small')).toHaveText('40 of 102 images · more load as you scroll');
await expect(page.locator('#workspace-toast')).toHaveText('');
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(page.locator('#workspace-toast')).toHaveText('40 more images loaded. 80 of 102.');
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(page.locator('#workspace-toast')).toHaveText('22 more images loaded. 102 of 102.');
await expect(page.locator('.infinite-continuation p.small')).toHaveText('You’re all caught up · 102 images');
await search(page).fill('no-such-image');
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('.infinite-continuation p.small')).toHaveText('No matching images · 0 images');
});
test('Load more focus never falls to the page when the last page hides the button', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
// The last page repeats already listed files: nothing new to focus, and Load more disappears.
await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => {
const response = await route.fetch();
const json = await response.json();
await route.fulfill({ response, json: { ...json, images: [], has_more: false, next_cursor: null } });
});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
await page.keyboard.press('Enter');
await expect(more).toBeHidden();
await expect(page.locator('article[aria-posinset="40"] .image-open')).toBeFocused();
expect(await page.evaluate(() => document.activeElement !== document.body)).toBe(true);
});
test('a search with no match shows the concept empty state', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const empty = page.locator('.gallery-body > .empty');
await expect(empty).toBeHidden();
await search(page).fill('no-such-image');
await expect(cards(page)).toHaveCount(0);
await expect(empty).toBeVisible();
await expect(empty).toHaveText('No matching images. Try another search or category.');
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(empty).toBeHidden();
});
test('a Load more that finishes after focus moved on leaves focus where the user put it', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
let release, arrived;
const held = new Promise(resolve => { release = resolve; });
const started = new Promise(resolve => { arrived = resolve; });
await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => { arrived(); await held; await route.continue(); }, { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await started;
await search(page).focus();
release();
await expect(cards(page)).toHaveCount(80);
await expect(search(page)).toBeFocused();
});
// #249 C3: the concept media card (a396–399), its activation (D19) and the concept grid breakpoints (D3).
const CARD_PATHS = () => {
// Tag and class paths below the card; icon internals are one `svg.icon`.
const walk = (node, prefix, out) => {
for (const child of node.children) {
const name = child.tagName.toLowerCase();
const classes = [...child.classList].filter(value => value !== 'selected').sort();
const path = `${prefix} > ${name}${classes.map(value => `.${value}`).join('')}`;
out.push(path);
if (name !== 'svg') walk(child, path, out);
}
return out;
};
return walk;
};
async function cardInventory(page, concept) {
return page.locator(concept ? '#image-grid > article.media-card' : 'article[data-image-key]').evaluateAll((nodes, {walkSource, concept}) => {
const walk = new Function(`return (${walkSource})()`)();
return nodes.map(card => {
const copy = card.cloneNode(true);
// Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
const open = card.querySelector('.image-open'), img = open.querySelector('img');
const star = card.querySelector('.image-overlays > button.star-button');
const box = card.querySelector('.image-overlays > label > input');
return { filename: open.getAttribute('aria-label').replace(/^Open /, ''),
// The concept's alt is the card title plus its demo-only " · sample illustration N" (m5).
alt: img.alt.replace(/ · sample illustration \d+$/, ''),
size: [img.getAttribute('width'), img.getAttribute('height')],
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
});
}, { walkSource: CARD_PATHS.toString(), concept });
}
test('card matches the concept structure', async ({ page, request }) => {
await seedConcept(request);
await page.setViewportSize({ width: 1600, height: 1100 });
await openConcept(page, 'images', 'dark');
const concept = new Map((await cardInventory(page, true)).map(card => [card.filename, card]));
expect(concept.size).toBeGreaterThan(5);
await openProduction(page, 'images', 'dark');
await expect(cards(page)).toHaveCount(24);
const production = await cardInventory(page, false);
const listed = (await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json()).images;
let compared = 0;
for (const card of production) {
const reference = concept.get(card.filename);
if (!reference) continue;
compared++;
// D10 title (the linked positive's, also for pair links), D11 "W × H · PNG · Seed N", the concept's
// --media-ratio, image alt and size attributes, Inspect label, the D7 star (the fixture stars the concept's) and markup.
expect(card, card.filename).toEqual(reference);
}
expect(compared).toBeGreaterThan(5);
expect(production.filter(card => card.star?.pressed === 'true').length).toBeGreaterThan(0);
// D10/D20: a pair-linked file's inspector heading is the positive's title, as the concept panel's.
const pairLinked = 'alpine_00001.png';
expect(concept.get(pairLinked)).toBeTruthy();
await page.getByRole('button', { name: `Inspect ${pairLinked}`, exact: true }).click();
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(concept.get(pairLinked).title);
await openConcept(page, 'images', 'dark');
await page.locator(`#image-grid [aria-label="Inspect ${pairLinked}"]`).click();
await expect(page.locator('#image-inspector h2')).toHaveText(concept.get(pairLinked).title);
await openProduction(page, 'images', 'dark');
await expect(cards(page)).toHaveCount(24);
const first = cards(page).first();
// The h3 keeps the full path; no byte size and no "Preview " button remain on the card.
for (const image of listed.slice(0, 6)) {
const card = page.locator(`article[data-image-key="${image.root_index}:${image.path}"]`);
await expect(card.locator('.media-info h3')).toHaveAttribute('title', image.path);
}
await expect(page.locator('article[data-image-key]').getByRole('button', { name: /^Preview / })).toHaveCount(0);
expect(await cards(page).evaluateAll(nodes => nodes.filter(node => /\d\s?(?:B|KB|MB|GB)\b/.test(node.textContent)).length)).toBe(0);
await expect(first.locator('.image-card-footer, small, strong')).toHaveCount(0);
// D10: anything but a confirmed link shows the filename; D11: the seed is the string as sent, unknown parts omitted.
await page.route(imagePages, async route => {
const response = await route.fetch(), json = await response.json();
const [a, b, c, ...rest] = json.images;
await route.fulfill({ response, json: { ...json, images: [
{ ...a, linked: { ...a.linked, status: 'ambiguous' }, seed: '9007199254740993' },
{ ...b, width: null, height: null, seed: null },
{ ...c, linked: { status: 'unavailable', positive_id: null, title: null, title_source: null, category: null, rating: null } },
...rest] } });
});
await page.goto('about:blank'); await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
const [a, b, c] = listed;
await expect(cards(page).nth(0).locator('.media-info h3')).toHaveText(a.filename);
await expect(cards(page).nth(0).locator('.media-info p')).toHaveText(`${a.width} × ${a.height} · PNG · Seed 9007199254740993`);
await expect(cards(page).nth(1).locator('.media-info h3')).toHaveText(b.linked.title);
await expect(cards(page).nth(1).locator('.media-info p')).toHaveText('PNG');
await expect(cards(page).nth(2).locator('.media-info h3')).toHaveText(c.filename);
});
test('image click opens the viewer and Info selects the panel', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// D19: the first load still selects the first image into the panel.
await expect(cards(page).first()).toHaveClass(/selected/);
await expectPanelSource(page, 'output');
const viewer = page.locator('dialog#viewer-dialog');
const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true });
await image.click();
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await expect(viewer.locator('.viewer-canvas img')).toHaveAttribute('src', /root=1/);
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
await expect(image).toBeFocused();
for (const key of ['Enter', ' ']) {
await image.focus(); await page.keyboard.press(key);
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
await expect(image).toBeFocused();
}
const info = cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true });
await expect(info.locator('svg.icon')).toHaveCount(1);
await info.click();
await expect(viewer).toHaveCount(0);
await expect(cards(page).nth(1)).toHaveClass(/selected/);
await expect(cards(page).first()).not.toHaveClass(/selected/);
await expect(info).not.toHaveAttribute('aria-pressed');
await expectPanelSource(page, 'input');
await image.focus(); await page.keyboard.press('ArrowLeft');
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expectPanelSource(page, 'input');
});
test('grid columns follow concept breakpoints', async ({ page, request }) => {
await seedConcept(request);
const expected = { comfortable: { 1600: 3, 1800: 4, 1200: 2, 390: 2 }, large: { 1600: 2, 1800: 2, 1200: 2, 390: 2 },
compact: { 1600: 4, 1800: 5, 1200: 3, 390: 2 } };
const rowLength = selector => page.locator(selector).evaluateAll(nodes => {
const top = nodes[0].getBoundingClientRect().top;
return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length;
});
for (const [url, selector] of [[null, '#image-grid > article.media-card'], [workspace, 'article[data-image-key]']]) {
for (const width of [1600, 1800, 1200, 390]) {
await page.setViewportSize({ width, height: 1100 });
if (url) { await page.goto('about:blank'); await page.goto(url); } else await openConcept(page, 'images', 'dark');
await expect.poll(() => page.locator(selector).count()).toBeGreaterThan(5);
for (const density of ['comfortable', 'large', 'compact']) {
await page.locator('#image-size').selectOption(density);
await expect.poll(() => rowLength(selector), `${url ? 'production' : 'concept'} ${density} ${width}`).toBe(expected[density][width]);
if (url) {
// The columns come from concept.css: the grid's own tracks, not a production breakpoint table.
expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length))
.toBe(expected[density][width]);
await cards(page).first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown');
await expect(cards(page).nth(expected[density][width]).locator('.image-open')).toBeFocused();
}
}
}
}
});
// `cssZoom` follows the suite's zoom convention (media queries keep the 1600px breakpoint); the 800px variant is a real
// 200% browser zoom: half the CSS viewport at device scale 2, so the concept's narrower breakpoint applies too (m4).
async function zoomedGallery(page, request, { cssZoom }) {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
if (cssZoom) await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
if (cssZoom) await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
}
await expect.poll(() => cards(page).count()).toBeLessThan(120);
// Every mounted card keeps the concept structure inside its reserved box.
expect(await cards(page).evaluateAll(nodes => nodes.filter(node => {
const box = node.getBoundingClientRect(), info = node.querySelector('.media-info').getBoundingClientRect();
return info.bottom > box.bottom + 1 || info.right > box.right + 1 || !node.querySelector('.media-art > .image-open');
}).length)).toBe(0);
await page.evaluate(() => window.scrollTo(0, 0));
const first = cards(page).first();
await first.locator('.image-open').focus();
await page.keyboard.press('End');
await expect(page.locator('article[aria-posinset="202"] .image-open')).toBeFocused();
await page.keyboard.press('Home');
await expect(page.locator('article[aria-posinset="1"] .image-open')).toBeFocused();
const columns = await cards(page).evaluateAll(nodes => {
const top = nodes[0].getBoundingClientRect().top;
return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length;
});
await page.keyboard.press('ArrowDown');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused();
// The concept's card order: image, the D8 checkbox, the D7 star, then Inspect.
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays input[type="checkbox"]`)).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
await page.evaluate(() => {
const card = document.querySelector('article[aria-posinset="1"]');
const grid = document.querySelector('.image-grid');
window.scrollTo(0, (grid.getBoundingClientRect().top + window.scrollY) + card.offsetHeight * 30);
});
await expect.poll(() => cards(page).count()).toBeLessThan(120);
const previousScroll = await page.evaluate(() => window.scrollY);
expect(previousScroll).toBeGreaterThan(1000);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(caption(page)).toContainText('202 of 202 images');
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(240);
await expect.poll(() => cards(page).count()).toBeLessThan(120);
if (cssZoom) await page.evaluate(() => { document.documentElement.style.zoom = ''; });
}
test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async ({ page, request }) => {
await zoomedGallery(page, request, { cssZoom: true });
});
test.describe('at an 800px viewport and device scale 2', () => {
test.use({ viewport: { width: 800, height: 550 }, deviceScaleFactor: 2 });
test('bounded DOM, keyboard and scroll restoration survive at real 200% zoom', async ({ page, request }) => {
await zoomedGallery(page, request, { cssZoom: false });
// The concept's narrow breakpoint, read from the grid's own tracks.
expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length)).toBe(2);
});
});
test('a debounced search that lands after a sort change does not reload the same page', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const fetches = await recordFetches(page);
// The sort change applies the typed query at once; the search debounce then reports the same values.
await search(page).fill('same');
await sortBy(page).selectOption('name');
// HTML timers of equal delay run in the order they were set, so a timer of the toolbar's own debounce delay set
// now runs after the debounced report.
await page.evaluate(async () => {
const { SEARCH_DELAY } = await import(new URL('js/workspace/toolbar.mjs', document.baseURI).href);
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY));
});
const reads = (await fetches()).filter(item => imagePages(new URL(`http://host${item.path}${item.search}`)));
expect(reads.filter(item => new URLSearchParams(item.search).get('q') === 'same')).toHaveLength(1);
});
// #249 D18: the concept's hover zoom (1.03 over 0.2s); reduced motion makes it instant, and a touch device (hover: none)
// has no sticky zoom after a tap.
for (const motion of ['no-preference', 'reduce']) {
test(`card image zooms on hover per concept (${motion})`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: motion });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const card = cards(page).first();
const img = card.locator('.image-open img');
const touch = await page.evaluate(() => matchMedia('(hover: none)').matches);
expect(await img.evaluate(n => getComputedStyle(n).transform)).toBe('none');
await card.locator('.image-open').hover();
// Touch: the emulated hover must not leave a zoom behind.
await expect.poll(() => img.evaluate(n => getComputedStyle(n).transform)).toBe(touch ? 'none' : 'matrix(1.03, 0, 0, 1.03, 0, 0)');
expect(await img.evaluate(n => getComputedStyle(n).transitionDuration)).toBe(motion === 'reduce' ? '0s' : '0.2s');
});
}
// D7 = A: the concept's star (app.js mediaCard, actions.favorite) against the PR B favorites store.
const favoriteRoute = '**/prompt_manager/images/favorite';
const star = (card, filename) => card.getByRole('button', { name: `Favorite ${filename}`, exact: true });
const toast = page => page.locator('#workspace-toast');
const listedFavorites = async request => Object.fromEntries((await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json())
.images.map(image => [`${image.root_index}:${image.path}`, image.favorite]));
async function expectStar(button, favorite) {
await expect(button).toHaveText(favorite ? '★' : '☆');
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
}
test('favorite persists across reload', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const sent = [];
page.on('request', item => {
if (new URL(item.url()).pathname === '/prompt_manager/images/favorite') {
sent.push({ method: item.method(), type: item.headers()['content-type'], body: item.postDataJSON() });
}
});
await page.goto(workspace);
const input = page.locator('article[data-image-key="1:shared/same.png"]');
const video = page.locator('article[data-image-key="0:clip.webm"]');
await expect(input).toBeVisible(); await expect(video).toBeVisible();
// The star sits in the concept's overlay on image and video cards alike.
await expect(input.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
await expect(video.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
await expectStar(star(input, 'same.png'), false);
await expectStar(star(video, 'clip.webm'), false);
const selected = await page.locator('article.selected').getAttribute('data-image-key');
expect(selected).not.toBe('1:shared/same.png');
await star(input, 'same.png').click();
await expectStar(star(input, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
// The star neither opens the viewer nor inspects the card.
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
await expect(page.locator('article.selected')).toHaveAttribute('data-image-key', selected);
await expect(star(input, 'same.png')).toBeFocused();
// Keyboard: Enter stars the video, Space clears it again.
await star(video, 'clip.webm').focus();
await page.keyboard.press('Enter');
await expectStar(star(video, 'clip.webm'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
// One request per file at a time: a press while the star is still busy is ignored, so wait for it to settle.
await expect(star(video, 'clip.webm')).not.toHaveAttribute('aria-disabled', 'true');
await page.keyboard.press(' ');
await expectStar(star(video, 'clip.webm'), false);
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
await expect.poll(() => sent.length).toBe(3);
expect(sent).toEqual([
{ method: 'PUT', type: 'application/json', body: { root_index: 1, path: 'shared/same.png' } },
{ method: 'PUT', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
{ method: 'DELETE', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
]);
// Favorites are keyed by source: the same file name in the other root stays unstarred.
await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': true, '0:shared/same.png': false, '0:clip.webm': false });
await page.reload();
await expectStar(star(input, 'same.png'), true);
await expectStar(star(page.locator('article[data-image-key="0:shared/same.png"]'), 'same.png'), false);
await expectStar(star(video, 'clip.webm'), false);
await star(input, 'same.png').click();
await expectStar(star(input, 'same.png'), false);
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': false });
await page.reload();
await expectStar(star(input, 'same.png'), false);
// Leaving Images and coming back restores the browsing state with the current star.
await star(input, 'same.png').click();
await expect(toast(page)).toHaveText('Added to favorites.');
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expectStar(star(input, 'same.png'), true);
});
test('favorite failure keeps the previous state and announces it', async ({ page, request }) => {
await page.goto(workspace);
const card = page.locator('article[data-image-key="0:shared/same.png"]');
const button = star(card, 'same.png');
await expectStar(button, false);
const held = [];
await page.route(favoriteRoute, route => { held.push(route); });
const fetches = await recordFetches(page);
const favoriteFetches = async () => (await fetches()).filter(item => item.path === '/prompt_manager/images/favorite').length;
// Optimistic while in flight; a second press during the request sends nothing more.
await button.click();
await expectStar(button, true);
// The pending star says it is busy, so the ignored second press is not a silent no-op.
await expect(button).toHaveAttribute('aria-disabled', 'true');
// Playwright waits for an aria-disabled button to enable; a user can still press it, so force the press.
await button.click({ force: true });
expect(await favoriteFetches()).toBe(1);
await expect.poll(() => held.length).toBe(1);
await held[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
await expectStar(button, false);
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
await expect(toast(page)).toHaveText('Favorite could not be added. Try again.');
expect((await listedFavorites(request))['0:shared/same.png']).toBe(false);
// Removal failure keeps the star; a network failure is announced the same way.
await page.unroute(favoriteRoute);
await button.click();
await expectStar(button, true);
await expect(toast(page)).toHaveText('Added to favorites.');
await page.route(favoriteRoute, route => route.abort('failed'));
await button.click();
await expectStar(button, true);
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
await page.unroute(favoriteRoute);
await page.reload();
await expectStar(star(card, 'same.png'), true);
expect((await listedFavorites(request))['0:shared/same.png']).toBe(true);
// A failure that lands after leaving Images still restores the star the user comes back to, and is announced.
const late = [];
await page.route(favoriteRoute, route => { late.push(route); });
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), false);
await expect.poll(() => late.length).toBe(1);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await late[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
await page.evaluate(() => { window.location.hash = '/images'; });
await expectStar(star(card, 'same.png'), true);
await page.unroute(favoriteRoute);
// One request per file at a time, across leaving and returning: the request outlives the page that sent it, the
// returned page still waits for it, and its answer reaches the card the user is looking at.
const across = [];
await page.route(favoriteRoute, route => { across.push(route); });
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), false);
await expect.poll(() => across.length).toBe(1);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expectStar(star(card, 'same.png'), false);
await expect(star(card, 'same.png')).toHaveAttribute('aria-disabled', 'true');
// The page was reloaded above: record afresh, from the ignored press on.
const afterReturn = await recordFetches(page);
await star(card, 'same.png').click({ force: true });
expect((await afterReturn()).filter(item => item.path === '/prompt_manager/images/favorite')).toEqual([]);
expect(across.length).toBe(1);
await expectStar(star(card, 'same.png'), false);
await across[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
await expectStar(star(card, 'same.png'), true);
await expect(star(card, 'same.png')).not.toHaveAttribute('aria-disabled', 'true');
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
// The rolled-back star is also what the next return shows, and the server never changed.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expectStar(star(card, 'same.png'), true);
expect((await listedFavorites(request))['0:shared/same.png']).toBe(true);
});
// #249 final review I1: the old Refresh button showed that it ran; Import → Rescan says so too, and when it ends.
test('Import Rescan shows that it runs, ignores a second press and announces the outcome', async ({ page, request }) => {
const held = [];
await page.route('**/prompt_manager/gallery/rescan', route => { held.push(route); });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await rescan(page);
await expect.poll(() => held.length).toBe(1);
await expect(toast(page)).toHaveText('Rescanning output folders…');
// Reopened while the scan runs, Rescan says it is running; a press on it is ignored and keeps the dialog open.
await page.getByRole('button', { name: 'Import', exact: true }).click();
const running = importDialog(page).getByRole('button', { name: 'Rescanning…', exact: true });
await expect(running).toHaveAttribute('aria-disabled', 'true');
await running.click({ force: true });
await expect(importDialog(page)).toBeVisible();
expect(held.length).toBe(1);
await expect(cards(page)).toHaveCount(2);
// The scan's own answer: every file in both fixture roots (output and input).
await held[0].continue();
await expect(toast(page)).toHaveText('Rescan finished. 3 files in 2 output folders.');
const idle = importDialog(page).getByRole('button', { name: 'Rescan', exact: true });
await expect(idle).not.toHaveAttribute('aria-disabled', 'true');
await page.keyboard.press('Escape');
await expect(cards(page)).toHaveCount(3);
await expect(caption(page)).toContainText('3 of 3 images');
});
// #249 final review I2: a polite live region re-announces replaced text, so an unchanged status is never rewritten.
test('a star press leaves the continuation status, count and caption date untouched', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.locator('#gallery-continuation p')).toHaveText(/caught up/);
await expect(caption(page).locator('span').nth(1)).toBeVisible();
await page.evaluate(() => {
window.__statusMutations = [];
const observer = new MutationObserver(records => {
window.__statusMutations.push(...records.map(record => `${record.type}:${record.target.nodeName}`));
});
for (const node of [document.querySelector('#gallery-continuation p'), document.querySelector('#result-count'),
document.querySelector('.section-caption > span:nth-child(2)')]) {
observer.observe(node, { childList: true, characterData: true, subtree: true, attributes: true });
}
});
const card = page.locator('article[data-image-key="0:shared/same.png"]');
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
});
// Opened before the source and folder discovery answered (a slow server), Refine lists them once it does.
test('Refine opened before discovery settles lists the discovered sources and folders', async ({ page }) => {
let release, started;
const held = new Promise(resolve => { release = resolve; }), hit = new Promise(resolve => { started = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?*', async route => { started(); await held; await route.continue(); }, { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await hit;
await filtersButton(page).click();
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const values = select => select.locator('option').evaluateAll(options => options.map(option => option.value));
expect(await values(source)).toEqual(['all']);
expect(await values(folder)).toEqual(['']);
release();
await expect.poll(() => values(source)).toEqual(['all', '0', '1']);
expect(await values(folder)).toEqual(['', 'shared']);
await expect(source).toHaveValue('all');
await source.selectOption('1');
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
// #249 final review m1: as in the old field row, a Source offers only its own folders.
test('a search reload keeps the Filters label node, so a click landing during the reload is not lost', async ({ page, request }) => {
// WebKit drops a click whose mousedown hit a text node that was replaced before mouseup; the reload reports the
// (unchanged) active count, so the label must keep its node.
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.goto(workspace);
const button = filtersButton(page);
await button.click();await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
await refine(page, { Source: '0', Folder: 'bulk' });
await expect(button).toHaveText('Filters · 2');
const label = await button.evaluateHandle(node => node.firstChild);
const reloaded = page.waitForResponse(response => {
const url = new URL(response.url());return imagePages(url) && url.searchParams.get('q') === 'png';
});
await search(page).fill('png');
await reloaded;
await expect(button).toHaveText('Filters · 2');
expect(await button.evaluate((node, text) => node.firstChild === text && text.isConnected, label)).toBe(true);
});
test('Refine offers only the chosen source folders', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
await page.getByRole('button', { name: 'Filters', exact: true }).click();
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const values = () => folder.locator('option').evaluateAll(options => options.map(option => option.value));
expect(await values()).toEqual(['', 'project', 'project/day', 'shared']);
await folder.selectOption('project');
await source.selectOption('1');
expect(await values()).toEqual(['', 'shared']);
await expect(folder).toHaveValue('');
await source.selectOption('0');
expect(await values()).toEqual(['', 'project', 'project/day', 'shared']);
await folder.selectOption('shared');
await source.selectOption('1');
await expect(folder).toHaveValue('shared');
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
// Reopened on that Source, the dialog again lists only its folders. Source and Folder count (D44).
await page.getByRole('button', { name: 'Filters · 2', exact: true }).click();
expect(await values()).toEqual(['', 'shared']);
});
/**
* Record every fetch the page's code calls from now on, as `{method, path, search}`. The API client calls fetch in the
* same task as the action that needs it, so after a press the record already holds any request the press started.
*/
async function recordFetches(page) {
await page.evaluate(() => {
window.__fetches = [];
const original = window.fetch;
window.fetch = (resource, init = {}) => {
const url = new URL(String(resource instanceof Request ? resource.url : resource), window.location.href);
window.__fetches.push({ method: (init.method || 'GET').toUpperCase(), path: url.pathname, search: url.search });
return original.call(window, resource, init);
};
});
return () => page.evaluate(() => window.__fetches);
}
/** The listing page reads among them (the sidebar count's `limit=1` read is not one). */
async function countImageFetches(page) {
const fetches = await recordFetches(page);
return async () => (await fetches()).filter(item => imagePages(new URL(`http://host${item.path}${item.search}`))).length;
}
// #249 final review m2: like the toolbar, an unchanged refinement reloads nothing, so the loaded cards (and with them
// the reader's place) stay as they were.
test('Apply or Clear without a change keeps the loaded page', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1200, height: 700 });
await page.goto(workspace);
await expect(caption(page)).toContainText(/of 82 images/);
const fetches = await countImageFetches(page);
await cards(page).first().evaluate(card => { card.dataset.kept = 'yes'; });
const before = await caption(page).textContent();
for (const action of ['Apply filters', 'Clear filters']) {
await page.getByRole('button', { name: 'Filters', exact: true }).click();
await refineDialog(page).getByRole('button', { name: action, exact: true }).click();
await expect(refineDialog(page)).toBeHidden();
expect(await fetches()).toBe(0);
await expect(cards(page).first()).toHaveAttribute('data-kept', 'yes');
await expect(caption(page)).toHaveText(before);
}
// A real change still applies.
await refine(page, { Media: 'image' });
await expect.poll(fetches).toBe(1);
});
// #249 final review m6: discovery and categories failing together on mount give one alert, not two.
test('unreachable sources and categories announce one error', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.abort('failed'));
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('alert')).toHaveText('Sources, folders and categories could not be loaded. Rescan from Import to retry.');
await expect(page.locator('.options-status')).toHaveCount(1);
await page.unroute('**/prompt_manager/gallery/subfolders?*');
await page.unroute('**/prompt_manager/library/options*');
await rescan(page);
await expect(page.getByRole('alert')).toHaveCount(0);
});
// PR D-b Task 3 (D42): the shared toast keeps the concept's fixed bottom placement, so a discovery alert that a rescan
// leaves visible must not stay under it. The window ends just below the alert, inside the toast's band, as on a short
// window or a phone with its browser bars shown.
const boxOf = locator => locator.evaluate(node => {
const { top, right, bottom, left } = node.getBoundingClientRect();
return { top, right, bottom, left };
});
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
for (const width of [1600, 390]) {
test(`Rescan toast does not cover the discovery alert it leaves at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
const failed = 'Categories could not be loaded. Rescan from Import to retry.';
await expect(alert).toHaveText(failed);
const toastInset = width > 700 ? 22 : 12;
await page.setViewportSize({ width, height: Math.ceil((await boxOf(alert)).bottom) + toastInset + 16 });
await rescan(page);
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toHaveText(failed);
await expect.poll(async () => overlaps(await boxOf(alert), await boxOf(toast(page)))).toBe(false);
const [shown, placed] = [await boxOf(alert), await boxOf(toast(page))];
expect(shown.top).toBeGreaterThanOrEqual(0);
expect(shown.bottom).toBeLessThanOrEqual(placed.top);
// The concept's toast stays where the concept puts it: centred, its inset from the bottom.
const viewport = page.viewportSize();
expect(Math.round(viewport.height - placed.bottom)).toBe(toastInset);
expect(Math.abs((placed.left + placed.right) / 2 - viewport.width / 2)).toBeLessThan(1);
});
}
// Final review m3: D42 scrolls only when the alert meets the toast's band, never back up to one above the window.
test('Rescan does not scroll back to a discovery alert above the window', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
await expect(alert).toHaveText('Categories could not be loaded. Rescan from Import to retry.');
await page.setViewportSize({ width: 1600, height: 260 });
// Import is at the top; the page is scrolled down to the last card while its dialog is open.
await page.getByRole('button', { name: 'Import', exact: true }).click();
await cards(page).last().evaluate(node => node.scrollIntoView({ block: 'end' }));
expect((await boxOf(alert)).bottom).toBeLessThan(0);
// The reload itself may move the page; what D42 must not do is scroll the alert back into view.
await alert.evaluate(node => {
window.alertScrolls = 0;
node.scrollIntoView = (...args) => { window.alertScrolls += 1; Element.prototype.scrollIntoView.apply(node, args); };
});
await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toBeAttached();
expect(await page.evaluate(() => window.alertScrolls)).toBe(0);
});
// #249 final review m8: D36's alphabetical categories hold whatever order the server sends.
test('toolbar categories are alphabetical regardless of server order', async ({ page }) => {
await page.route('**/prompt_manager/library/options*', route => route.fulfill({
json: { success: true, categories: ['zeta', 'Alpha', 'beta'] },
}));
await page.goto(workspace);
const category = page.getByRole('combobox', { name: 'Filter by category', exact: true });
await expect(category.locator('option')).toHaveText(['All categories', 'Alpha', 'beta', 'zeta']);
});
// #249 D8 follow-up (image selection, Task 1): the concept's card checkbox and selection bar (app.js 398, 426-434).
const bulkbar = page => page.locator('#workspace-main > #bulkbar');
const checkbox = (card, filename) => card.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
const barButtons = page => bulkbar(page).locator('.bulkbar > button');
test('card checkboxes select images into the concept bar and Clear empties it', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// The concept's place: an empty bar between the filter row and the gallery layout.
await expect(bulkbar(page)).toBeEmpty();
expect(await bulkbar(page).evaluate(bar => [bar.previousElementSibling.className, bar.nextElementSibling.classList.contains('gallery-layout')]))
.toEqual(['filter-row', true]);
// Each overlay starts with the checkbox label, then the star.
expect(await page.locator('.image-overlays').evaluateAll(rows => rows.map(row => [...row.children].map(child =>
`${child.tagName.toLowerCase()}${child.querySelector(':scope > input') ? `>${child.firstElementChild.type}` : ''}`))))
.toEqual([['label>checkbox', 'button'], ['label>checkbox', 'button']]);
const [first, second] = [cards(page).first(), cards(page).last()];
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore);
// The count updates in place: the same bar and count nodes.
await bulkbar(page).locator('.bulkbar > strong').evaluate(node => { node.dataset.kept = 'yes'; });
await checkbox(second, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes');
// Set category opens its dialog; Cancel keeps the selection.
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
await page.getByRole('dialog', { name: 'Categorize selected images', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await checkbox(second, 'same.png').uncheck();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await checkbox(second, 'same.png').check();
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(bulkbar(page)).toBeEmpty();
// The pressed control hid with its bar: focus moves to the page heading, not to the page.
await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(checkbox(first, 'same.png')).not.toBeChecked();
await expect(checkbox(second, 'same.png')).not.toBeChecked();
});
test('the card checkbox keeps its own Space and Tab', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const first = cards(page).first();
await first.getByRole('button', { name: 'Open same.png', exact: true }).focus();
await page.keyboard.press('Tab');
await expect(checkbox(first, 'same.png')).toBeFocused();
await page.keyboard.press('Space');
await expect(checkbox(first, 'same.png')).toBeChecked();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(star(first, 'same.png')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(checkbox(first, 'same.png')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(first.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
// An arrow key on the checkbox still moves between cards, as from the card's other controls.
await checkbox(first, 'same.png').focus();
await page.keyboard.press('ArrowRight');
await expect(cards(page).last().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(checkbox(first, 'same.png')).toBeChecked();
});
test('the selection survives scrolling, re-mounting and appended batches, and a search clears it', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
await cards(page).first().getByRole('checkbox', { name: /^Select / }).check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
}
// A card from a later batch joins the kept selection.
const late = page.locator('article[data-image-key][data-index="200"]');
await late.getByRole('checkbox', { name: /^Select / }).check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
// Bounded rendering unmounted the first card; scrolled back, it is mounted again with its box checked.
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toHaveCount(0);
await page.evaluate(() => window.scrollTo(0, 0));
const again = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(again).toBeVisible();
await expect(again.getByRole('checkbox', { name: /^Select / })).toBeChecked();
await expect(cards(page).nth(1).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
// Returning to Images keeps the restored collection and its selection.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
// A search replaces the collection, and with it the selection.
await search(page).fill('0001');
await expect(caption(page)).not.toContainText('202 images');
await expect(bulkbar(page)).toBeEmpty();
await search(page).fill('');
await expect(caption(page)).toContainText('of 202 images');
await expect(bulkbar(page)).toBeEmpty();
await expect(page.locator(`article[data-image-key="${firstKey}"]`).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
// A sort change replaces the collection too.
await cards(page).first().getByRole('checkbox', { name: /^Select / }).check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await sortBy(page).selectOption('oldest');
await expect(bulkbar(page)).toBeEmpty();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
});
test('the card checkbox does not open the viewer', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const first = cards(page).first();
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
const inspectorBefore = await details(page).innerText();
// A press on the label around the box toggles it, as does a press on the box itself.
await first.locator('.image-overlays > label').click({ position: { x: 1, y: 1 } });
await expect(checkbox(first, 'same.png')).toBeChecked();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await checkbox(first, 'same.png').click();
await expect(checkbox(first, 'same.png')).not.toBeChecked();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
// Neither press selected the card or inspected it.
expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore);
expect(await details(page).innerText()).toBe(inspectorBefore);
});
// #249 D8 follow-up (image selection, Task 2): Export metadata downloads one bundle of the inspector's envelopes.
const exportButton = page => bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
const inspectRoute = '**/prompt_manager/images/inspect/**';
const atRoot = (route, root) => new URL(route.request().url()).searchParams.get('root') === root;
async function checkBoth(page) {
await expect(cards(page)).toHaveCount(2);
await checkbox(cards(page).first(), 'same.png').check();
await checkbox(cards(page).last(), 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
}
async function bundleOf(download) {
const fs = await import('node:fs/promises');
return JSON.parse(await fs.readFile(await download.path(), 'utf8'));
}
test('Export metadata downloads one bundle of the checked images in listing order and keeps the selection', async ({ page }) => {
// Record the Blob URLs the page creates and revokes, to prove the download's URL is released.
await page.addInitScript(() => {
const blobs = window.__blobs = { created: [], revoked: [] };
const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = value => { const url = create(value); blobs.created.push(url); return url; };
URL.revokeObjectURL = url => { blobs.revoked.push(url); revoke(url); };
});
await page.goto(workspace);
await checkBoth(page);
const keys = await cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey));
// Hold both reads: the button is busy and a second press starts nothing more.
const held = [], reads = [];
await page.route(inspectRoute, route => { reads.push(route.request().url()); held.push(route); });
const downloads = [];
page.on('download', download => downloads.push(download));
await exportButton(page).click();
await expect.poll(() => held.length).toBe(2);
await expect(exportButton(page)).toHaveAttribute('aria-disabled', 'true');
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
await exportButton(page).dispatchEvent('click');
await page.evaluate(() => new Promise(requestAnimationFrame));
const saving = page.waitForEvent('download');
for (const route of held) await route.continue();
const download = await saving;
expect(download.suggestedFilename()).toBe('prompt-manager-images-metadata.json');
const bundle = await bundleOf(download);
expect([bundle.format, bundle.version]).toEqual(['prompt-manager-images-metadata', 1]);
expect(Number.isNaN(Date.parse(bundle.exported_at))).toBe(false);
expect(bundle.images.map(image => image.identity.key)).toEqual(keys);
expect(bundle.images.map(image => image.identity.path.split('/').at(-1))).toEqual(['same.png', 'same.png']);
expect(bundle.images.every(image => image.inspection_version === 1 && image.metadata.prompt_graph === undefined)).toBe(true);
await expect(toast(page)).toHaveText('Exported metadata for 2 images.');
await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true');
expect(reads).toHaveLength(2);
expect(downloads).toHaveLength(1);
// The bundle's one Blob URL is revoked once the download started.
await expect.poll(() => page.evaluate(() => window.__blobs)).toEqual({ created: [expect.stringMatching(/^blob:/)], revoked: [expect.stringMatching(/^blob:/)] });
const blobs = await page.evaluate(() => window.__blobs);
expect(blobs.revoked).toEqual(blobs.created);
// The selection is kept after an export.
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
});
test('Export metadata reports images that could not be read and exports the rest', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
await page.route(inspectRoute, route => atRoot(route, '1') ?
route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }) : route.continue());
const [download] = await Promise.all([page.waitForEvent('download'), exportButton(page).click()]);
const bundle = await bundleOf(download);
expect(bundle.images.map(image => image.identity.key)).toEqual(['0:shared/same.png']);
await expect(toast(page)).toHaveText('Exported metadata for 1 of 2 images. 1 could not be read.');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
});
test('Export metadata downloads nothing when no image can be read', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
await page.route(inspectRoute, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
const downloads = [];
page.on('download', download => downloads.push(download));
await exportButton(page).click();
await expect(toast(page)).toHaveText('Metadata could not be read. Try again.');
await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true');
expect(downloads).toHaveLength(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
});
// #332 (D140, MC6, MC7): Set category writes each selected image's own category (PUT /prompt_manager/images/category);
// "Use prompt category" removes it after a confirmation (DELETE). It never writes a prompt: Images no longer posts
// /prompt_manager/bulk/category (#259's prompt action is retired from Images).
const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true });
const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
const removeDialog = page => page.getByRole('dialog', { name: 'Use prompt category', exact: true });
const categoryFilter = page => page.getByRole('combobox', { name: 'Filter by category', exact: true });
const categoryRoute = '**/prompt_manager/images/category';
const IMAGE_CATEGORY = '/prompt_manager/images/category';
const cardOf = (page, key) => page.locator(`article[data-image-key="${key}"]`);
const keyOf = image => `${image.root_index}:${image.path}`;
const cardKeys = page => cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey).sort());
// The four outputs of "Cat portrait" (CUJ 1's batch); the input root's same.png is linked to "Dog landscape".
const BATCH = ['0:bulk/0000.png', '0:bulk/0001.png', '0:bulk/0002.png', '0:shared/same.png'];
/** Every category write the page sends, Images' own route and #259's prompt route alike. */
function categoryWrites(page) {
const writes = [];
page.on('request', item => {
const path = new URL(item.url()).pathname;
if (item.method() === 'GET' || !['/prompt_manager/images/category', '/prompt_manager/bulk/category'].includes(path)) return;
writes.push({ method: item.method(), path, body: item.postDataJSON() });
});
return writes;
}
async function listedImages(request, query = '') {
const response = await request.get(`/prompt_manager/images/output?mode=workspace&limit=40${query}`);
await expect(response).toBeOK();
return (await response.json()).images;
}
async function targetsOf(request, keys) {
const images = await listedImages(request);
return keys.map(key => {
const image = images.find(item => keyOf(item) === key);
return { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
});
}
async function putOwnCategory(request, keys, category) {
const response = await request.put(IMAGE_CATEGORY, { data: { items: await targetsOf(request, keys), category } });
await expect(response).toBeOK();
}
const filteredKeys = async (request, category) => (await listedImages(request, `&category=${encodeURIComponent(category)}`)).map(keyOf).sort();
async function positiveOf(request, id) {
const response = await request.get(`/prompt_manager/library/${id}`);
await expect(response).toBeOK();
const { positive } = await response.json();
return { id: positive.id, text: positive.text, category: positive.category };
}
/** CUJ 1's Given: four images linked to the positive "Cat portrait", whose category is "Landscapes". */
async function seedBatch(request) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
const response = await request.post('/prompt_manager/save', { data: { text: 'Cat portrait', category: 'Landscapes' } });
await expect(response).toBeOK();
const id = (await response.json()).prompt_id;
expect(await filteredKeys(request, 'Landscapes')).toEqual(BATCH);
return id;
}
async function openSetCategory(page) {
await categoryButton(page).click();
await expect(categoryDialog(page)).toBeVisible();
return categoryDialog(page);
}
async function applyCategory(page, category) {
const dialog = await openSetCategory(page);
await dialog.getByLabel('Category', { exact: true }).fill(category);
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog).toBeHidden();
}
test('Set category overrides one image of a batch and never writes its prompt (#332 CUJ 1)', async ({ page, request }) => {
const positiveId = await seedBatch(request);
const before = await positiveOf(request, positiveId);
const writes = categoryWrites(page);
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
const dialog = await openSetCategory(page);
// The concept's simpleInput plus "Use prompt category" between Cancel and Apply (primary).
await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category');
await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue('');
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Use prompt category', 'Apply']);
await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/);
await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused();
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
// An empty value applies nothing and keeps the dialog open.
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Category', { exact: true }).fill(' Portraits ');
const listings = [];
page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); });
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
body: { items: await targetsOf(request, ['0:bulk/0001.png']), category: 'Portraits' } }]);
// The listing and the category options reload; the reload clears the selection.
await expect.poll(() => listings.length).toBeGreaterThan(0);
await expect(bulkbar(page)).toBeEmpty();
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
await categoryFilter(page).selectOption('Portraits');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
await categoryFilter(page).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(3);
expect(await cardKeys(page)).toEqual(BATCH.filter(key => key !== '0:bulk/0001.png'));
// The prompt keeps its category: only the image carries "Portraits".
expect(await positiveOf(request, positiveId)).toEqual(before);
expect(before.category).toBe('Landscapes');
expect(writes.filter(write => write.path === '/prompt_manager/bulk/category')).toEqual([]);
});
test('Set category categorizes unlinked images and lists the value in the toolbar and the suggestions (#332 CUJ 2)', async ({ page, request }) => {
// Unlink both images by deleting their prompts.
for (const image of await listedImages(request)) {
await expect(await request.delete(`/prompt_manager/delete/${image.linked.positive_id}`)).toBeOK();
}
expect((await listedImages(request)).filter(image => image.linked.status === 'linked')).toEqual([]);
const writes = categoryWrites(page);
await page.goto(workspace);
await checkBoth(page);
await applyCategory(page, 'Scans');
await expect(toast(page)).toHaveText('Category “Scans” set on 2 images.');
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
body: { items: await targetsOf(request, ['0:shared/same.png', '1:shared/same.png']), category: 'Scans' } }]);
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Scans']);
await categoryFilter(page).selectOption('Scans');
await expect(cards(page)).toHaveCount(2);
expect(await cardKeys(page)).toEqual(['0:shared/same.png', '1:shared/same.png']);
// The field suggests the toolbar's categories through its own datalist.
await checkbox(cards(page).first(), 'same.png').check();
const dialog = await openSetCategory(page);
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('list', 'workspace-input-suggestions');
expect(await dialog.locator('datalist#workspace-input-suggestions option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['Scans']);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('Use prompt category removes the own category only after Remove in its confirmation (#332 CUJ 3)', async ({ page, request }) => {
const positiveId = await seedBatch(request);
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
const writes = categoryWrites(page);
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
// Cancel in the confirmation sends nothing and keeps the selection.
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
await expect(categoryDialog(page)).toHaveCount(0);
const confirm = removeDialog(page);
await expect(confirm).toBeVisible();
await expect(confirm.locator('p').first()).toHaveText("Remove the image category from the 1 selected image? Each one then shows its prompt's category, or none if it has no linked prompt. This cannot be undone.");
await expect(confirm.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Remove']);
await expect(confirm.getByRole('button', { name: 'Remove', exact: true })).toHaveClass(/danger/);
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(selectedCount(page)).toHaveText('1 selected');
expect(writes).toEqual([]);
expect(await filteredKeys(request, 'Portraits')).toEqual(['0:bulk/0001.png']);
// Remove sends exactly one DELETE naming the selected image.
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
await removeDialog(page).getByRole('button', { name: 'Remove', exact: true }).click();
await expect(toast(page)).toHaveText('Removed the image category from 1 image.');
expect(writes).toEqual([{ method: 'DELETE', path: IMAGE_CATEGORY, body: { items: await targetsOf(request, ['0:bulk/0001.png']) } }]);
await expect(bulkbar(page)).toBeEmpty();
// The image shows its prompt's category again: "Landscapes" lists all four, and "Portraits" is gone.
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes']);
await categoryFilter(page).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(4);
expect(await cardKeys(page)).toEqual(BATCH);
expect((await positiveOf(request, positiveId)).category).toBe('Landscapes');
});
test('a saved view filtered by a category lists an image categorized after it was saved (#332 CUJ 8)', async ({ page, request }) => {
await seedBatch(request);
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
await page.goto(workspace);
await categoryFilter(page).selectOption('Portraits');
await expect(cards(page)).toHaveCount(1);
await page.locator('#workspace-main search.toolbar').getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Portraits');
await save.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
// Another image gets "Portraits" through Set category.
await categoryFilter(page).selectOption('');
await expect(cards(page)).toHaveCount(5);
await checkbox(cardOf(page, '0:bulk/0002.png'), '0002.png').check();
await applyCategory(page, 'Portraits');
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
// Reopening the view lists both.
await page.getByRole('navigation', { name: 'Saved views', exact: true }).getByRole('link', { name: 'Portraits', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Portraits');
await expect(categoryFilter(page)).toHaveValue('Portraits');
await expect(cards(page)).toHaveCount(2);
expect(await cardKeys(page)).toEqual(['0:bulk/0001.png', '0:bulk/0002.png']);
});
test('at 390px in light theme the Set category dialog and the category chip fit (#332 CUJ 10)', async ({ page, request }) => {
const LONG = 'Portraits taken on a phone at the end of a long walk through the old harbour at dusk';
await page.addInitScript(() => { try { localStorage.setItem('pm_workspace_theme', 'light'); } catch { /* the default theme */ } });
await page.setViewportSize({ width: 390, height: 844 });
await seedBatch(request);
await putOwnCategory(request, ['0:bulk/0001.png'], LONG);
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
const card = cardOf(page, '0:bulk/0001.png');
// The chip wraps inside the side panel; its origin never splits.
await card.getByRole('button', { name: 'Inspect 0001.png', exact: true }).click();
const chip = details(page).locator('span.chip', { has: page.locator('span.chip-source') });
await expect(chip).toHaveText(`Image category: ${LONG} · this image`);
const fit = await chip.evaluate(node => {
const panel = node.closest('aside, [role="complementary"]').getBoundingClientRect(), box = node.getBoundingClientRect();
const source = node.querySelector('.chip-source');
const line = parseFloat(getComputedStyle(source).lineHeight) || source.getBoundingClientRect().height;
return { inside: box.left >= panel.left - 0.5 && box.right <= panel.right + 0.5, sourceLines: source.getClientRects().length,
wrapped: box.height > line * 1.5, pageScroll: document.documentElement.scrollWidth - window.innerWidth };
});
expect(fit).toEqual({ inside: true, sourceLines: 1, wrapped: true, pageScroll: 0 });
// The dialog fits the width with no horizontal scroll, and its three buttons wrap without overlap.
await checkbox(card, '0001.png').check();
const dialog = await openSetCategory(page);
const geometry = await dialog.evaluate(node => {
const box = node.getBoundingClientRect();
const buttons = [...node.querySelectorAll('.dialog-actions > button')].map(button => button.getBoundingClientRect())
.map(({ left, right, top, bottom }) => ({ left, right, top, bottom }));
const overlaps = buttons.some((a, i) => buttons.some((b, j) => j > i && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom));
const scrollers = [node, ...node.querySelectorAll('*')].filter(item => item.scrollWidth > item.clientWidth + 1 && getComputedStyle(item).overflowX !== 'visible');
return { fits: box.left >= 0 && box.right <= window.innerWidth, scrollers: scrollers.length, count: buttons.length, overlaps,
inside: buttons.every(b => b.left >= box.left && b.right <= box.right), pageScroll: document.documentElement.scrollWidth - window.innerWidth };
});
expect(geometry).toEqual({ fits: true, scrollers: 0, count: 3, overlaps: false, inside: true, pageScroll: 0 });
});
test('Set category of more than 500 images says so before any dialog or request (#332)', async ({ page, request }) => {
await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({ response, json: { ...body, total: 501 } });
});
await bulkReset(page, request);
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await bulkbar(page).getByRole('button', { name: 'Select all 501 matching', exact: true }).click();
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
const writes = categoryWrites(page);
await categoryButton(page).click();
await expect(toast(page)).toHaveText('Select at most 500 images.');
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(writes).toEqual([]);
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
});
test('a changed source says so and reloads the listing (#332)', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
await page.route(categoryRoute, route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The output sources changed. Refresh the images and try again.' } }));
const reloaded = page.waitForRequest(item => imagePages(new URL(item.url())));
await applyCategory(page, 'Landscapes');
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
await reloaded;
await expect(cards(page)).toHaveCount(2);
// The reload hides the bar with Set category in it: focus moves to the page heading, as after a success.
await expect(page.locator('#workspace-main h1')).toBeFocused();
});
test('a recipe-linked image is listed under its recipe\'s category (#332 D142)', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { native_library: true } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Animals', 'Experiments', 'Portraits']);
// The output same.png is linked to recipe "Portrait variant 1" (Portraits), the input copy to variant 2 (Experiments);
// only the image without a recipe shows its positive's "Animals".
for (const [category, keys] of [['Portraits', ['0:shared/same.png']], ['Experiments', ['1:shared/same.png']], ['Animals', ['0:legacy-unassigned.png']]]) {
await categoryFilter(page).selectOption(category);
await expect(cards(page)).toHaveCount(keys.length);
expect(await cardKeys(page)).toEqual(keys);
}
});
test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(categoryRoute, route => { held.push(route); });
await applyCategory(page, 'Landscapes');
await expect.poll(() => held.length).toBe(1);
await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true');
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
await categoryButton(page).dispatchEvent('click');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await held[0].fulfill({ status: 500, json: { success: false, error: 'boom' } });
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(categoryButton(page)).not.toHaveAttribute('aria-disabled', 'true');
expect(held).toHaveLength(1);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
});
test('leaving Images during an export downloads nothing and shows no toast', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(inspectRoute, route => { held.push(route); });
const downloads = [];
page.on('download', download => downloads.push(download));
await exportButton(page).click();
await expect.poll(() => held.length).toBe(2);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// The reads were aborted with the page; answering them now changes nothing.
for (const route of held) await route.continue().catch(() => {});
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
expect(downloads).toHaveLength(0);
await expect(toast(page)).not.toContainText('etadata');
});
test('leaving Images during Set category finishes the write, and the return reloads the collection', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(categoryRoute, route => { held.push(route); });
await applyCategory(page, 'Landscapes');
await expect.poll(() => held.length).toBe(1);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// The write is not aborted with the page: the server still receives and answers it.
const answered = page.waitForResponse(response => new URL(response.url()).pathname === IMAGE_CATEGORY);
await held[0].continue();
expect((await answered).ok()).toBe(true);
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
await expect(toast(page)).not.toContainText('Category');
// The saved visit predates the write, so the return loads the collection afresh: no stale selection or categories.
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(bulkbar(page)).toBeEmpty();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
await expect(categoryFilter(page).locator('option')).toContainText(['Landscapes']);
});
test('Set category shows the server counts and coded refusals, and a rejection keeps the selection', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const answers = [
{ status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } },
{ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } },
{ status: 200, json: { success: true, category: 'Landscapes', updated: 1, unchanged: 0, not_found: 1 } },
];
await page.route(categoryRoute, route => route.fulfill(answers.shift()));
for (const expected of ['Category could not be set. Try again.',
'None of the selected images could be found. Refresh the images and try again.',
'Category “Landscapes” set on 1 image. 1 could not be found.']) {
const dialog = await openSetCategory(page);
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
await dialog.getByLabel('Category', { exact: true }).fill('Landscapes');
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(toast(page)).toHaveText(expected);
if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
}
// The success reload clears the selection.
await expect(bulkbar(page)).toBeEmpty();
expect(answers).toHaveLength(0);
});
// Activity-1 Task 3 (D55): Import → "Scan for metadata" → Review scan opens the concept's "Review metadata scan".
const scanReview = page => page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
async function openScanReview(page) {
await page.getByRole('button', { name: 'Import', exact: true }).click();
const row = importDialog(page).locator('.operation').filter({ has: page.getByRole('heading', { name: 'Scan for metadata', exact: true }) });
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(scanReview(page)).toBeVisible();
}
test('scan for metadata opens the review dialog from Import', async ({ page, request }) => {
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// Rescan stays the quick refresh; the metadata scan is its own row under it.
await page.getByRole('button', { name: 'Import', exact: true }).click();
await expect(importDialog(page).locator('.operation h3')).toHaveText(['Inspect a local image', 'Scan an output folder', 'Scan for metadata']);
await expect(importDialog(page).locator('.operation').nth(2).locator('p')).toHaveText('Discover and enrich existing files using the configured sources.');
await page.keyboard.press('Escape');
await openScanReview(page);
const dialog = scanReview(page);
await expect(dialog.locator('dl.meta-grid > div > dt')).toHaveText(['Sources', 'Scope']);
await expect(dialog.locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await expect(dialog.locator('#modal-body > p.small')).toHaveText('Enrich positive / negative associations, reuse canonical text and report unchanged files. Do not inflate run counts or recreate existing thumbnails.');
await expect(dialog.locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Start scan']);
await expect(dialog.getByRole('button', { name: 'Start scan', exact: true })).toHaveClass(/ca-primary/);
// Back up now downloads the existing backup, toasts, and keeps the dialog open for the scan.
const download = page.waitForEvent('download');
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
expect((await download).suggestedFilename()).toMatch(/^prompts_backup_\d{8}_\d{6}\.db$/);
await expect(toast(page)).toHaveText('Database backup downloaded.');
await expect(dialog).toBeVisible();
// A failed backup says so and downloads nothing.
await page.route('**/prompt_manager/backup', route => route.fulfill({ status: 500, json: { success: false, error: 'disk full' } }));
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
await expect(toast(page)).toHaveText('The database could not be backed up. Try again.');
// Cancel closes it and starts nothing.
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(scanReview(page)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeFocused();
// No scan started: the one job is the backup's finished registry hold
// (#249 S8: a backup download holds a guarded slot while it streams).
await expect.poll(async () => (await (await request.get('/prompt_manager/jobs')).json()).jobs
.map(job => [job.kind, job.state])).toEqual([['backup', 'finished']]);
});
// #392 (D135): file-manager selection beyond the concept's checkbox.
const selectedCount = page => bulkbar(page).locator('.bulkbar > strong');
const cardAt = (page, index) => page.locator(`article[data-image-key][data-index="${index}"]`);
const boxAt = (page, index) => cardAt(page, index).getByRole('checkbox', { name: /^Select / });
const checkedIndexes = page => page.locator('article[data-image-key]').evaluateAll(nodes => nodes
.filter(node => node.querySelector('.image-overlays input[type="checkbox"]').checked)
.map(node => Number(node.dataset.index)).sort((a, b) => a - b));
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index);
async function bulkReset(page, request) {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
}
/** A touch press on `locator` the way a finger makes one: pointerdown, (held), pointerup. */
const touchDown = locator => locator.evaluate(node => {
const { left, top } = node.getBoundingClientRect();
node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, isPrimary: true, pointerType: 'touch', clientX: left + 5, clientY: top + 5 }));
});
const touchUpAndClick = locator => locator.evaluate(node => {
node.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, isPrimary: true, pointerType: 'touch' }));
// A tap's click counts one press (`detail` 1); a keyboard click (and `HTMLElement.click()`) counts none.
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 }));
});
async function touchTap(locator) {
await touchDown(locator);
await touchUpAndClick(locator);
}
test('Shift+click selects a range across an appended batch in Grid, List and Masonry', async ({ page, request }) => {
await bulkReset(page, request);
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(caption(page)).toContainText('80 of 202 images');
for (const mode of ['Grid', 'List', 'Masonry']) {
await page.getByRole('group', { name: 'Image layout', exact: true }).getByRole('button', { name: mode, exact: true }).click();
// The anchor is the last image toggled on its own (List hides the concept's checkbox: the card carries it).
await cardAt(page, 3).locator('.media-info h3').click({ modifiers: ['ControlOrMeta'] });
await expect(selectedCount(page)).toHaveText('1 selected');
// The range runs into the second batch (index 40 on), and backwards from the same anchor.
await cardAt(page, 45).locator('.media-info h3').click({ modifiers: ['Shift'] });
await expect(selectedCount(page)).toHaveText('43 selected');
expect(await checkedIndexes(page)).toEqual(range(3, 45));
await cardAt(page, 0).locator('.media-info h3').click({ modifiers: ['Shift'] });
await expect(selectedCount(page)).toHaveText('46 selected');
expect(await checkedIndexes(page)).toEqual(range(0, 45));
await expect(page.locator('dialog[open]')).toHaveCount(0);
if (mode !== 'List') {
// A Shift+click on a checkbox gives its range the box's new state: here, unchecked.
await boxAt(page, 3).uncheck();
await boxAt(page, 10).click({ modifiers: ['Shift'] });
await expect(boxAt(page, 10)).not.toBeChecked();
await expect(selectedCount(page)).toHaveText('38 selected');
expect(await checkedIndexes(page)).toEqual([0, 1, 2, ...range(11, 45)]);
}
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(bulkbar(page)).toBeEmpty();
}
});
test('Ctrl/Cmd+click toggles a card without opening it, and a plain click still opens the viewer', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const first = cards(page).first();
const inspectorBefore = await details(page).innerText();
await first.getByRole('button', { name: 'Open same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] });
await expect(selectedCount(page)).toHaveText('1 selected');
await expect(checkbox(first, 'same.png')).toBeChecked();
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] });
await expect(selectedCount(page)).toHaveText('2 selected');
await star(first, 'same.png').click({ modifiers: ['ControlOrMeta'] });
await expect(selectedCount(page)).toHaveText('1 selected');
await expect(star(first, 'same.png')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(await details(page).innerText()).toBe(inspectorBefore);
// A plain click keeps its meaning: the image opens the viewer.
await first.getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
await expect(selectedCount(page)).toHaveText('1 selected');
});
test('Ctrl/Cmd+A, Select all N matching and Add tags tag every matching image on the server', async ({ page, request }) => {
await bulkReset(page, request);
// Ctrl/Cmd+A in the search box stays the field's own.
await search(page).focus();
await page.keyboard.press('ControlOrMeta+a');
await expect(bulkbar(page)).toBeEmpty();
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await expect(selectedCount(page)).toHaveText('40 selected');
const offer = bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true });
await expect(offer).toBeVisible();
await expect(bulkbar(page).locator('.bulkbar > *').nth(1)).toHaveText('Select all 202 matching');
await offer.click();
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
await expect(offer).toHaveCount(0);
await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).toHaveAttribute('aria-disabled', 'true');
// A later batch arrives checked.
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(caption(page)).toContainText('80 of 202 images');
await expect(boxAt(page, 70)).toBeChecked();
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
// Unchecking one image leaves "all matching": the other loaded images stay selected.
await boxAt(page, 0).uncheck();
await expect(selectedCount(page)).toHaveText('79 selected');
await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
await cardAt(page, 1).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
// Add tags sends the listing filter, not 202 identities, and the server tags every match.
await bulkbar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Tag selected images', exact: true });
await dialog.getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('picked392');
const started = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags');
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
expect((await started).postDataJSON()).toEqual({ mode: 'add', filter: { sort: 'newest' }, tags: ['picked392'] });
await expect.poll(async () => (await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&tags=picked392')).json()).total,
{ timeout: 20000 }).toBe(202);
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
// Escape in the grid clears the selection.
await cardAt(page, 2).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('Escape');
await expect(bulkbar(page)).toBeEmpty();
await expect(boxAt(page, 2)).not.toBeChecked();
});
test('Shift+arrow keys extend the selection from the focused card, and Escape in the bar clears it', async ({ page, request }) => {
await bulkReset(page, request);
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.press('Shift+ArrowRight');
await expect(cardAt(page, 2).getByRole('button', { name: /^Open / })).toBeFocused();
await expect(selectedCount(page)).toHaveText('3 selected');
expect(await checkedIndexes(page)).toEqual([0, 1, 2]);
// A plain arrow moves without selecting; Shift+End reaches the last loaded card from the same anchor.
await page.keyboard.press('ArrowRight');
await expect(selectedCount(page)).toHaveText('3 selected');
await page.keyboard.press('Shift+End');
await expect(selectedCount(page)).toHaveText('40 selected');
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(bulkbar(page)).toBeEmpty();
await expect(page.locator('#workspace-main h1')).toBeFocused();
});
test('a touch long-press starts selection mode, taps then toggle, and an empty selection ends it', async ({ page }, testInfo) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const [first, second] = [cards(page).first(), cards(page).last()];
const open = card => card.getByRole('button', { name: 'Open same.png', exact: true });
await touchDown(open(first));
// The context menu of a held press belongs to the long press.
expect(await open(first).evaluate(node => !node.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })))).toBe(true);
// Held: the long press selects the card once its delay passed.
await expect(selectedCount(page)).toHaveText('1 selected');
await touchUpAndClick(open(first));
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeChecked();
// Taps now toggle instead of opening.
if (testInfo.project.name === 'touch') await open(second).tap();
else await touchTap(open(second));
await expect(selectedCount(page)).toHaveText('2 selected');
// A keyboard Enter keeps its meaning in touch selection mode: the image opens.
await open(first).focus();
await page.keyboard.press('Enter');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
await expect(selectedCount(page)).toHaveText('2 selected');
await touchTap(open(first));
await touchTap(open(second));
await expect(bulkbar(page)).toBeEmpty();
await expect(page.locator('dialog[open]')).toHaveCount(0);
// The selection is empty, so touch selection mode ended: a tap opens the image again.
await touchTap(open(first));
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
});
test('all matching sets the category through the listing filter and survives a return visit as the loaded images', async ({ page, request }) => {
await bulkReset(page, request);
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
// The offer left with its press: focus moves to the bar's first action.
await expect(bulkbar(page).getByRole('button', { name: 'Add tags', exact: true })).toBeFocused();
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
// Export metadata waits for loaded images: a press does nothing.
const exportButton = bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
await expect(exportButton).toHaveAttribute('title', 'Export metadata reads each image in the browser, so it works on loaded images only.');
// No match listed any more (a stubbed answer): the not-found sentence, and the selection stays.
await page.route(categoryRoute, route => route.fulfill({ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } }), { times: 1 });
await applyCategory(page, 'Bulk392');
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
// The real write: one PUT with the listing filter sets every matching image's own category; no prompt changes.
const writes = categoryWrites(page);
await applyCategory(page, 'Bulk392');
await expect(toast(page)).toHaveText('Category “Bulk392” set on 202 images.');
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY, body: { filter: { sort: 'newest' }, category: 'Bulk392' } }]);
await expect(bulkbar(page)).toBeEmpty();
const filtered = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&category=Bulk392')).json();
expect(filtered.total).toBe(202);
expect((await (await request.get('/prompt_manager/library/options')).json()).categories).not.toContain('Bulk392');
// A return visit keeps "all matching" as the loaded images, and the offer shows again.
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(selectedCount(page)).toHaveText('40 selected');
await expect(bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true })).toBeVisible();
// Escape in the bar with nothing selected does nothing; with a selection it clears.
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(bulkbar(page)).toBeEmpty();
});
test('all matching over 10,000 images says so before any dialog or request', async ({ page, request }) => {
await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({ response, json: { ...body, total: 20002 } });
});
await bulkReset(page, request);
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
await page.keyboard.press('ControlOrMeta+a');
await bulkbar(page).getByRole('button', { name: 'Select all 20,002 matching', exact: true }).click();
await expect(selectedCount(page)).toHaveText('All 20,002 matching selected');
let posted = 0;
page.on('request', item => { if (item.method() !== 'GET') posted += 1; });
// Set category names at most 500 images (#332); the tagging jobs take up to 10,000.
for (const [action, sentence] of [['Set category', 'Select at most 500 images.'], ['Add tags', 'Narrow the view to at most 10,000 images.'],
['Auto-tag selected', 'Narrow the view to at most 10,000 images.']]) {
await bulkbar(page).getByRole('button', { name: action, exact: true }).click();
await expect(toast(page)).toHaveText(sentence);
await expect(page.locator('dialog[open]')).toHaveCount(0);
}
expect(posted).toBe(0);
});