diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index e6dae5d..ad3eead 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -93,7 +93,7 @@ async function downloadText(page,action) { const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')}; } -test('full inspector shows six facts and scoped provenance',async({page,request})=>{ +test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page); await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); @@ -110,10 +110,21 @@ test('full inspector shows six facts and scoped provenance',async({page,request} await panel.getByText('File & provenance',{exact:true}).click(); await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file'); await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); + await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible(); + if (testInfo.project.name === 'chromium') { + await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded(); + const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'}); + } await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/); await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible(); await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1); + await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click(); + await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible(); + if (testInfo.project.name === 'chromium') { + await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded(); + const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'}); + } }); test('full inspector preserves long role text and exact sends',async({page,request,context})=>{ diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 32508f5..d5db521 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -97,6 +97,66 @@ test('a short visible batch automatically continues until the sentinel is filled 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('.image-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: /^Preview / }); + await preview.click(); + const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true }); + 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('.image-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 preview', 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']); + await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '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); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 4926c97..b3edb3f 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -13,7 +13,7 @@ const REPRESENTATIONS = { invalid: 'Invalid container record; text is unavailable', }; const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; -const FACT_SOURCES = {'api-graph': 'Saved API graph', workflow: 'Saved workflow', 'embedded-parameters': 'Embedded generation parameters'}; +const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'}; function safeFilename(value) { return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8d9762f..12c7ea2 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -136,12 +136,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage main.append(body); const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender, onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, - onRefresh:refreshInspectionCollection,onRestoreFocus:image => { + onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => { const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true}); }}); scope.cleanup(() => mediaViewer.dispose()); - main.addEventListener('close',event => {if (event.target.matches('.image-preview')) scheduleLoad();},{capture:true,signal:scope.signal}); function mediaElement(image, lazy = false) { const video = image.is_video && !image.thumbnail_url; @@ -348,7 +347,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore(); } function scheduleLoad() { - if (!loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); }); + if (!scope.signal.aborted && !loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); }); } if (typeof IntersectionObserver === 'function') { observer = new IntersectionObserver(entries => { diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 2006af2..f68d072 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -17,7 +17,7 @@ function element(tag, className, text) { return node; } -export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus}) { +export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -83,6 +83,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se if (trigger?.isConnected) trigger.focus({preventScroll:true}); else onRestoreFocus?.(entry); } + if (!scope.signal.aborted) onClose?.(); } active = {close:closeViewer,stop:() => session.stop()}; local.cleanup(() => { diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 66450fa..113a791 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -113,4 +113,3 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role render({prompts}); return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; } -