From 8a6fb5feca1be908279a34a97bbfaa9fa9c7571d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 16 Oct 2026 05:48:40 -0700 Subject: [PATCH] fix: ignore stale metadata in admin image viewers Fixes #179 --- tests/js/admin-metadata.test.js | 50 ++++++++++++++++++++++++ tools/vendor/tests/viewer.spec.mjs | 62 ++++++++++++++++++++++++++++++ web/js/admin.js | 16 ++++++++ 3 files changed, 128 insertions(+) create mode 100644 tests/js/admin-metadata.test.js diff --git a/tests/js/admin-metadata.test.js b/tests/js/admin-metadata.test.js new file mode 100644 index 0000000..68abb6d --- /dev/null +++ b/tests/js/admin-metadata.test.js @@ -0,0 +1,50 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function fixture() { + const panel = { innerHTML: '', isConnected: true }; + const requests = new Map(); + const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {}, + getElementById: id => id === 'metadata-content' ? panel : null }, + window: {}, console: { error() {} }, + }); + const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context); + const manager = Object.create(Admin.prototype); + manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject })); + manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; }; + manager.hideModal = () => {}; + const load = url => manager.loadMetadataForImage({ getAttribute: () => url }); + return { manager, panel, requests, load }; +} +test('admin metadata and copy actions stay on the selected image', async () => { + const { manager, panel, requests, load } = fixture(); + const old = load('old.png'); + const current = load('current.png'); + requests.get('current.png').resolve({ imagePath: 'current.png' }); await current; + requests.get('old.png').resolve({ imagePath: 'old.png' }); await old; + assert.equal(panel.innerHTML, 'current.png'); + assert.equal(manager.currentMetadata.imagePath, 'current.png'); +}); +test('navigation clears old action data and late errors cannot replace current metadata', async () => { + const { manager, panel, requests, load } = fixture(); + manager.currentMetadata = { imagePath: 'previous.png' }; + const old = load('old.png'); + assert.equal(manager.currentMetadata, null); + const current = load('current.png'); + requests.get('current.png').resolve({ imagePath: 'current.png' }); await current; + requests.get('old.png').reject(new Error('old error')); await old; + assert.equal(panel.innerHTML, 'current.png'); +}); +for (const close of ['close gallery', 'detach sidebar']) { + test(`${close} prevents pending admin metadata from restoring copy state`, async () => { + const { manager, panel, requests, load } = fixture(); + const pending = load('old.png'); + if (close === 'close gallery') manager.closeGallery(); + else panel.isConnected = false; + requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending; + assert.equal(manager.currentMetadata, null); + assert.notEqual(panel.innerHTML, 'old.png'); + }); +} diff --git a/tools/vendor/tests/viewer.spec.mjs b/tools/vendor/tests/viewer.spec.mjs index f597288..ecd9317 100644 --- a/tools/vendor/tests/viewer.spec.mjs +++ b/tools/vendor/tests/viewer.spec.mjs @@ -206,3 +206,65 @@ test('metadata follows the selected image when responses finish out of order', a 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'); +}); diff --git a/web/js/admin.js b/web/js/admin.js index a38bd6c..157abd3 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -1656,6 +1656,7 @@ // Initialize ViewerJS for the gallery after DOM is updated setTimeout(() => { if (this.galleryViewer) { + this.invalidateMetadataRequest(); this.galleryViewer.destroy(); } @@ -1699,6 +1700,7 @@ viewed: (event) => { this.loadMetadataForImage(event.detail.image); }, + hide: () => this.invalidateMetadataRequest(), hidden: () => { const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) sidebar.remove(); @@ -1711,7 +1713,13 @@ + invalidateMetadataRequest() { + this.metadataRequest = null; + this.currentMetadata = null; + } + closeGallery() { + this.invalidateMetadataRequest(); this.hideModal("galleryModal"); } @@ -2896,6 +2904,11 @@ const metadataContent = document.getElementById('metadata-content'); if (!metadataContent) return; + const request = {}; + this.metadataRequest = request; + this.currentMetadata = null; + const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false; + // Show loading state metadataContent.innerHTML = `
@@ -2911,9 +2924,11 @@ try { const metadata = await this.extractImageMetadata(imageUrl); + if (!isCurrent()) return; this.currentMetadata = metadata; this.updateMetadataSidebar(metadata); } catch (error) { + if (!isCurrent()) return; console.error('Failed to load metadata:', error); metadataContent.innerHTML = `
@@ -3515,6 +3530,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; viewed: (event) => { this.loadMetadataForImage(event.detail.image); }, + hide: () => this.invalidateMetadataRequest(), hidden: () => { const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) sidebar.remove();