import { test, expect } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { repositoryRoot as root } from '../vendor-viewerjs.mjs'; const source = file => readFileSync(join(root, file), 'utf8'); async function waitForImage(page, index) { await expect.poll(() => page.evaluate(() => window.gallery.viewer?.viewed)).toBe(true); await expect.poll(() => page.evaluate(() => window.gallery.lastMetadata)).toContain(`/original/${index}.svg`); } async function loadGallery(page) { await page.addScriptTag({ content: source('web/js/gallery.js') + '\nwindow.TestGallery = GalleryManager;' }); await page.evaluate(() => { const manager = window.gallery = Object.create(TestGallery.prototype); Object.assign(manager, { viewer: null, viewMode: 'grid', images: Array.from({ length: 2000 }, (_, i) => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, thumbnail_url: `http://viewer.test/thumb/${i}.svg`, relative_path: `${i}.svg`, size: 100, modified_time: 1 })), getSettings: () => ({ showImageInfo: true }), loadImageMetadata(url, content) { this.lastMetadata = url; content.textContent = url; }, }); manager.renderGallery(); }); } test.beforeEach(async ({ page }) => { page.viewerErrors = []; page.on('pageerror', error => page.viewerErrors.push(error.message)); await page.route('http://viewer.test/**', route => route.request().resourceType() === 'document' ? route.fulfill({ contentType: 'text/html', body: '

' }) : route.fulfill({ contentType: 'image/svg+xml', body: '' })); await page.goto('http://viewer.test/'); await page.addStyleTag({ path: join(root, 'web/lib/tailwind/comfyui-theme.css') }); await page.addStyleTag({ path: join(root, 'web/lib/tailwind/styles.css') }); await page.addStyleTag({ path: join(root, 'web/lib/viewerjs/viewer.min.css') }); await page.addStyleTag({ content: '.hidden{display:none} #galleryGrid{display:grid;grid-template-columns:repeat(8,1fr)} #galleryGrid img{width:100px;height:100px}' }); for (const file of ['web/lib/viewerjs/viewer.min.js', 'web/js/html-escape.js', 'web/js/image-helpers.js', 'web/js/api-client.js']) { await page.addScriptTag({ path: join(root, file) }); } }); test.afterEach(async ({ page }) => { expect(page.viewerErrors).toEqual([]); }); test('gallery retains cards and navigates across bounded windows with correct metadata', async ({ page }) => { await loadGallery(page); await page.locator('[data-image-index="1500"]').click(); await waitForImage(page, 1500); expect(await page.evaluate(() => gallery.viewer.length)).toBeLessThanOrEqual(100); expect(await page.evaluate(() => { const first = document.querySelector('.image-item'); gallery.images.push({ ...gallery.images[0], filename: 'appended.svg' }); gallery.renderGallery({ from: 2000 }); return first === document.querySelector('.image-item'); })).toBe(true); await page.evaluate(() => gallery.viewer.view(99)); await waitForImage(page, 1549); await page.keyboard.press('ArrowRight'); await waitForImage(page, 1550); await page.keyboard.press('ArrowLeft'); await waitForImage(page, 1549); await page.keyboard.press('Escape'); await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false); await expect(page.locator('#metadata-sidebar')).toHaveCount(0); await page.locator('#outside').focus(); await expect(page.locator('#outside')).toBeFocused(); }); test('gallery slideshow advances without preloading the collection and cleans up on close', async ({ page }) => { await loadGallery(page); await page.evaluate(() => gallery.openViewer(1500)); await waitForImage(page, 1500); await page.clock.install(); await page.locator('[data-viewer-action="play"]').click(); expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(true); await expect(page.locator('.viewer-player img')).toHaveCount(0); await page.clock.fastForward(5000); await waitForImage(page, 1501); await page.locator('[data-viewer-action="play"]').click(); expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(false); await page.keyboard.press('Escape'); await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false); await page.evaluate(() => gallery.destroyViewer()); await expect(page.locator('.viewer-container')).toHaveCount(0); }); test('admin gallery and filmstrip use the real viewer events and destroy temporary containers', async ({ page }) => { // Exercise the production class without starting the ComfyUI dashboard bootstrap. const adminSource = source('web/js/admin.js'); await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' }); await page.evaluate(async () => { const admin = window.admin = Object.create(TestAdmin.prototype); const images = Array.from({ length: 3 }, (_, i) => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 })); Object.assign(admin, { prompts: [{ id: 1, text: 'test' }], getImageUrl: image => image.url, loadFilmStripImages: async () => images, addMetadataSidebar() {}, loadMetadataForImage(image) { this.lastMetadata = image.src; }, }); await admin.openFilmStripViewer(1, 1); }); await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/1.svg'); await page.keyboard.press('ArrowRight'); await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/2.svg'); await page.keyboard.press('Escape'); await expect(page.locator('#filmStripViewerContainer')).toHaveCount(0); await expect(page.locator('.viewer-container')).toHaveCount(0); await page.evaluate(() => admin.renderGallery([{ filename: 'a.svg', url: 'http://viewer.test/original/0.svg', size: 100, created_at: 1 }], 1)); await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true); await page.evaluate(() => admin.galleryViewer.show()); await expect.poll(() => page.evaluate(() => admin.galleryViewer.viewed)).toBe(true); await page.keyboard.press('Escape'); await expect.poll(() => page.evaluate(() => admin.galleryViewer.isShown)).toBe(false); await page.evaluate(() => admin.galleryViewer.destroy()); }); test('tag viewer opens at the selected image and cleans up on Escape', async ({ page }) => { await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') }); await page.evaluate(async () => { const tags = Object.create(TagsPageManager.prototype); tags.admin = { getImageUrl: image => image.url }; await tags.openImageViewer({ images: [0, 1, 2].map(i => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg` })), image_count: 3 }, 1); }); await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/1.svg$/); await page.keyboard.press('ArrowRight'); await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/2.svg$/); await page.keyboard.press('Escape'); await expect(page.locator('.viewer-container')).toHaveCount(0); }); test('touch swipes navigate the gallery and restore short drags', async ({ page, isMobile }) => { test.skip(!isMobile, 'Touch interaction project'); await loadGallery(page); await page.evaluate(() => gallery.openViewer(1500)); await waitForImage(page, 1500); await page.locator('.metadata-collapse-btn').click(); await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/); const session = await page.context().newCDPSession(page); const width = await page.evaluate(() => gallery.viewer.viewerData.width); const swipe = async distance => { await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: width * 0.9, y: 220 }] }); for (let step = 1; step <= 8; step++) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: width * 0.9 - distance * step / 8, y: 220 }] }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); }; await swipe(width * 0.8); await waitForImage(page, 1501); const initialX = await page.evaluate(() => gallery.viewer.imageData.x); await swipe(width * 0.1); await waitForImage(page, 1501); await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.x)).toBe(initialX); }); test('touch pinch zoom and rotation remain usable', async ({ page, isMobile }) => { test.skip(!isMobile, 'Touch interaction project'); await loadGallery(page); await page.evaluate(() => gallery.openViewer(1500)); await waitForImage(page, 1500); await page.locator('.metadata-collapse-btn').click(); await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/); const session = await page.context().newCDPSession(page); const points = (radius, angle = 0) => [-1, 1].map((sign, id) => ({ id, x: 400 + sign * radius * Math.cos(angle), y: 220 + sign * radius * Math.sin(angle), })); await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(30) }); for (let step = 1; step <= 6; step++) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(30 + step * 8) }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.ratio > 1.2)).toBe(true); await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(70) }); for (let step = 1; step <= 8; step++) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(70, Math.PI / 2 * step / 8) }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await expect.poll(() => page.evaluate(() => Math.abs(gallery.viewer.imageData.rotate))).toBe(90); }); test('metadata follows the selected image when responses finish out of order', async ({ page }) => { await loadGallery(page); await page.evaluate(() => { delete gallery.loadImageMetadata; window.metadataRequests = new Map(); gallery.extractImageMetadata = url => new Promise(resolve => metadataRequests.set(url, resolve)); gallery.openViewer(0); }); await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/0.svg'))).toBe(true); await page.evaluate(() => gallery.viewer.next()); await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/1.svg'))).toBe(true); await page.evaluate(() => metadataRequests.get('http://viewer.test/original/1.svg')({ positivePrompt: 'Selected prompt' })); await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); await page.evaluate(() => metadataRequests.get('http://viewer.test/original/0.svg')({ positivePrompt: 'Stale prompt' })); await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt'); expect(await page.evaluate(() => gallery.currentMetadata.positivePrompt)).toBe('Selected prompt'); await page.evaluate(() => gallery.viewer.next()); await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/2.svg'))).toBe(true); await page.keyboard.press('Escape'); await expect(page.locator('#metadata-sidebar')).toHaveCount(0); await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' })); expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull(); }); for (const mode of ['gallery', 'filmstrip']) { test(`admin ${mode} metadata ignores stale responses and clears copy state on close`, async ({ page }) => { const urlFor = index => mode === 'gallery' ? `http://viewer.test/prompt_manager/images/${index}/file` : `http://viewer.test/original/${index}.svg`; const adminSource = source('web/js/admin.js'); await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' }); await page.evaluate(async mode => { const admin = window.admin = Object.create(TestAdmin.prototype); const images = Array.from({ length: 3 }, (_, i) => ({ id: i, filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 })); window.pendingMetadata = new Map(); Object.assign(admin, { prompts: [{ id: 1, text: 'test' }], getImageUrl: image => image.url, loadFilmStripImages: async () => images, extractImageMetadata: url => new Promise(resolve => pendingMetadata.set(url, resolve)), }); if (mode === 'gallery') { admin.renderGallery(images, 1); } else { await admin.openFilmStripViewer(1, 0); } }, mode); if (mode === 'gallery') { await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true); await page.evaluate(() => admin.galleryViewer.show()); } await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(0))).toBe(true); await page.keyboard.press('ArrowRight'); await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(1))).toBe(true); await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Selected prompt', negativePrompt: '', imagePath: '1.svg' }), urlFor(1)); await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Stale prompt', negativePrompt: '', imagePath: '0.svg' }), urlFor(0)); await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt'); expect(await page.evaluate(() => admin.currentMetadata.imagePath)).toBe('1.svg'); await page.keyboard.press('ArrowRight'); await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(2))).toBe(true); await page.keyboard.press('Escape'); await expect(page.locator('#metadata-sidebar')).toHaveCount(0); await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Closed prompt', negativePrompt: '', imagePath: '2.svg' }), urlFor(2)); expect(await page.evaluate(() => admin.currentMetadata)).toBeNull(); }); } test('Tags selection stays current during a delayed prompt response', async ({ page }) => { await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') }); await page.evaluate(() => { const grid = document.createElement('div'); grid.id = 'tagPromptsGrid'; document.body.append(grid); const manager = window.tagsManager = Object.create(TagsPageManager.prototype); window.pendingTagPrompts = []; Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, prompts: [], fetchJson: url => new Promise(resolve => pendingTagPrompts.push({ url, resolve })), renderTagPrompts() { grid.textContent = this.prompts.map(prompt => prompt.text).join(','); }, updatePromptCountBadge() {}, updateLoadMoreButton() {}, refreshPromptObserver() {}, }); manager.loadTagPrompts(); manager.selectedTags = ['dogs']; manager.loadTagPrompts(); }); expect(await page.evaluate(() => pendingTagPrompts.length)).toBe(2); await page.evaluate(() => pendingTagPrompts[1].resolve({ success: true, prompts: [{ text: 'dog prompt' }], pagination: { total: 1, has_more: false } })); await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt'); await page.evaluate(() => pendingTagPrompts[0].resolve({ success: true, prompts: [{ text: 'cat prompt' }], pagination: { total: 1, has_more: false } })); await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt'); });