diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index ad3eead..9278de3 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -568,6 +568,8 @@ test('collection viewer matches approved responsive frame',async({page,request}, await page.setViewportSize({width:390,height:844}); for(const zoom of [1,2]) { await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom); + // Absolute gallery cards relayout through ResizeObserver and rAF after CSS zoom. + await expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:'Images gallery relayout settles the 390px viewport after CSS zoom'}).toEqual({inner:390,client:390,scroll:390}); expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1); expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true); await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); @@ -576,6 +578,15 @@ test('collection viewer matches approved responsive frame',async({page,request}, await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport(); await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click(); } + await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';}); + await rail.getByText('Workflow & raw metadata',{exact:true}).click(); + const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport(); + await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport(); + const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};}); + expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1); + expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'}); + await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')}); await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';}); const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0]; await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index d5db521..520c438 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -478,6 +478,92 @@ test('source and folder discovery failure stays visible and Refresh restores usa 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 }); + await page.goto(workspace); + const source = page.getByRole('combobox', { name: 'Source', exact: true }); + const folder = page.getByRole('combobox', { name: 'Folder', exact: true }); + await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('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 page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png'); + await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name'); + const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80'); + let oldPage; + await page.route('**/prompt_manager/images/output?*', 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-open`).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-card-footer button`).click(); + const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });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, 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(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue(''); + for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled(); + await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).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.source.dispatchEvent('change'); + 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.source).toBeEnabled();await expect(recovery.source).toHaveValue('all'); + await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]); + 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(recovery.source.locator('option')).toHaveText(['All sources']); + await page.getByRole('navigation', { name: 'Workspace', 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); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 36b7897..e7bcbaf 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -44,6 +44,22 @@ test('reset aborts pending requests and ignores stale success and failure', asyn assert.equal(stream.state.loading,false); }); +test('deferred reset cancels stale source work without fetching until discovery succeeds', async () => { + const {createImageStream}=await source;const requests=[]; + const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}}}); + const old=stream.reset({root:1,subfolder:'old'}); + const deferredReset=stream.reset({root:'all',q:'png',media:'image',sort:'name'},{load:false}); + assert.equal(requests[0].signal.aborted,true);assert.equal(requests.length,1); + assert.equal(await deferredReset,0); + assert.equal(stream.state.items.length,0);assert.equal(stream.state.cursor,null);assert.equal(stream.state.loading,false); + requests[0].resolve(page([image('old',1)],2,'stale'));assert.equal(await old,0);assert.equal(stream.state.items.length,0); + const fresh=stream.loadMore();assert.equal(requests.length,2); + const query=new URL(requests[1].url,'http://local').searchParams; + assert.equal(query.has('root'),false);assert.equal(query.has('subfolder'),false);assert.equal(query.get('q'),'png');assert.equal(query.get('media'),'image');assert.equal(query.get('sort'),'name'); + requests[1].resolve(page([image('fresh',0)]));assert.equal(await fresh,1);assert.equal(stream.state.items[0].path,'fresh'); + stream.dispose();assert.equal(await stream.reset({}, {load:false}),0);assert.equal(requests.length,2); +}); + test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => { const {createImageStream}=await source; const pending=deferred(); const calls=[]; const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)]; diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 63f543a..25cfdfe 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -114,7 +114,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re } } - async function reset(filters = {}) { + async function reset(filters = {}, {load = true} = {}) { if (disposed) return 0; const nextQuery = queryFor(filters, batchSize); controller?.abort(); @@ -123,7 +123,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re seen = new Set(); publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null, loaded: 0 }); - return loadMore(); + return load ? loadMore() : 0; } return { diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 12c7ea2..b4799fd 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -37,7 +37,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage let density = visit?.density || 'comfortable'; let metrics = workspaceImageMetrics(window.innerWidth, density); let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; - let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest; + let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false; const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; @@ -93,14 +93,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage try { await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); if (scope.signal.aborted) return; - await loadOptions(); - if (!scope.signal.aborted) reset(); + if (await loadOptions() && !scope.signal.aborted) reset(); } catch { if (scope.signal.aborted) return; optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.'; optionsStatus.hidden = false; } finally { - refresh.disabled = false; + refresh.disabled = recovering; } }); const currentGallery = el('a', 'Current gallery', 'workspace-link'); @@ -325,12 +324,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage state = next; count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`; if (changed) updateLayout(); - message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; + message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status'); retry.hidden = !next.error; more.hidden = !next.hasMore || !!next.error; - more.setAttribute('aria-disabled', String(next.loading)); - end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; + more.setAttribute('aria-disabled', String(next.loading || recovering)); + end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); } @@ -339,10 +338,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage stream.dispose(); for (const video of body.querySelectorAll('video')) releaseVideo(video); }); - scope.listen(more, 'click', () => stream.loadMore()); - scope.listen(retry, 'click', () => stream.loadMore()); + scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); }); + scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); }); function maybeLoad() { - if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return; + if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return; const bounds = continuation.getBoundingClientRect(); if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore(); } @@ -358,17 +357,29 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage function reset() { mediaViewer.stopSlideshow(); main.querySelector('.image-preview[open]')?.close(); - inspection.clear(); - detailInspector?.dispose(); detailInspector = null; + if (!recovering) { + inspection.clear(); + detailInspector?.dispose(); detailInspector = null; + details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); + } selection = null; for (const video of details.querySelectorAll('video')) releaseVideo(video); - details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); - stream.reset(filters); + stream.reset(filters, {load:!recovering}); } async function refreshInspectionCollection() { if (scope.signal.aborted) return; + // Numeric roots may now name another source; retire the old stream first. + recovering = true; + filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim(); + source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', '')); + for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true; + optionsStatus.hidden = true; + reset(); + const discovery = loadOptions(), request = optionsRequest; + if (!await discovery || scope.signal.aborted || request !== optionsRequest) return; + recovering = false; + for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false; reset(); - await loadOptions(); } async function loadOptions() { optionsRequest?.abort(); @@ -377,22 +388,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'}); if (filters.root !== 'all') params.set('root', filters.root); const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal}); - if (scope.signal.aborted || request !== optionsRequest) return; + if (scope.signal.aborted || request !== optionsRequest) return false; source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index)))); source.value = filters.root; folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path))); folder.value = filters.subfolder; optionsStatus.hidden = true; + return true; } catch { - if (scope.signal.aborted || request !== optionsRequest) return; - optionsStatus.textContent = 'Sources and folders could not be loaded. Refresh to retry.'; + if (scope.signal.aborted || request !== optionsRequest) return false; + optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.'; optionsStatus.hidden = false; + return false; } } scope.cleanup(() => optionsRequest?.abort()); - scope.listen(search, 'input', scope.debounce(() => { filters.q = search.value.trim(); reset(); }, 200)); + scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200)); for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) { scope.listen(input, 'change', () => { + if (recovering) return; filters[key] = input.value; if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); } reset(); diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css index 40d6b12..c360a7a 100644 --- a/web/js/workspace/inspector.css +++ b/web/js/workspace/inspector.css @@ -36,6 +36,7 @@ .file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } .file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; } .ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; } +.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; } .file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; } .file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; } .file-status { padding: 17px; }