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); });