import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { chromium } from 'playwright'; test('Gallery and Quick Edit Model Picker: identity, privacy, and synchronization', async () => { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); const mockModels = [ { id: 'sdxl', label: 'Stable Diffusion XL (Primary)', kind: 'edit', provider: 'Cluster A', endpoint_id: 101, // Non-admin view: base_url omitted }, { id: 'sdxl', label: 'Stable Diffusion XL (Secondary)', kind: 'edit', provider: 'Cluster B', endpoint_id: 202, // Non-admin view: base_url omitted }, { id: 'qwen-image', label: 'Qwen Image Edit', kind: 'edit', provider: 'Local Seneca', endpoint_id: 303, }, ]; await page.route('http://gallery-sync.local/**', async (route) => { const url = new URL(route.request().url()); const path = url.pathname; if (path === '/') { return route.fulfill({ contentType: 'text/html', body: `
`, }); } if (path === '/api/editor/image-models') { return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: mockModels }), }); } if (path === '/api/editor/agent-models') { return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: [] }), }); } try { const source = await readFile(new URL('..' + path, import.meta.url), 'utf8'); return route.fulfill({ contentType: 'text/javascript', body: source }); } catch (err) { return route.abort(); } }); await page.goto('http://gallery-sync.local/'); // --- Test 1: Option value encoding and distinct endpoint identities --- const evalResult1 = await page.evaluate(async () => { // Set legacy localStorage value that contained a private base URL localStorage.setItem('ge-ai-command-model', 'http://private-cluster.internal/v1::qwen-image'); const { wireAIModelSelectors } = await import('/static/js/editor/ai-models.js'); wireAIModelSelectors({ container: document.body, apiBase: '' }); // Run gallery initialization const { openGallery } = await import('/static/js/gallery.js'); try { openGallery(); } catch (_) {} // Wait a moment for network fetch await new Promise((r) => setTimeout(r, 80)); const gallerySel = document.getElementById('gallery-image-model'); const qeSel = document.getElementById('ge-ai-command-model'); const galleryOpts = Array.from(gallerySel.options).map((o) => ({ value: o.value, text: o.textContent })); const qeOpts = Array.from(qeSel.options).map((o) => ({ value: o.value, text: o.textContent })); const migratedKey = localStorage.getItem('ge-ai-command-model'); return { galleryOpts, qeOpts, galleryValue: gallerySel.value, qeValue: qeSel.value, migratedKey, }; }); // Check duplicate model id 'sdxl' produces distinguishable values with endpoint_id const sdxlGalleryOpts = evalResult1.galleryOpts.filter((o) => o.value.endsWith('::sdxl')); assert.equal(sdxlGalleryOpts.length, 2, 'Two endpoints offering sdxl produce two distinct options'); assert.equal(sdxlGalleryOpts[0].value, '101::sdxl'); assert.equal(sdxlGalleryOpts[1].value, '202::sdxl'); // Check no options contain undefined or empty prefix for (const opt of evalResult1.galleryOpts) { assert.equal(opt.value.startsWith('::'), false, 'Option value must not start with empty ::'); assert.equal(opt.value.includes('undefined'), false, 'Option value must not include undefined'); assert.equal(opt.value.includes('http://'), false, 'Option value must not expose raw http URL'); } // Check legacy migration: 'http://private-cluster.internal/v1::qwen-image' was migrated to '303::qwen-image' assert.equal(evalResult1.migratedKey, '303::qwen-image', 'Legacy key with URL migrated cleanly'); assert.equal(evalResult1.galleryValue, '303::qwen-image', 'Gallery picker selected migrated model'); assert.equal(evalResult1.qeValue, '303::qwen-image', 'Quick Edit picker selected migrated model'); // --- Test 2: Same-tab synchronization (Gallery -> Quick Edit) --- const evalResult2 = await page.evaluate(async () => { const gallerySel = document.getElementById('gallery-image-model'); gallerySel.value = '101::sdxl'; gallerySel.dispatchEvent(new Event('change')); await new Promise((r) => setTimeout(r, 40)); const qeSel = document.getElementById('ge-ai-command-model'); const statusEl = document.getElementById('ge-status-model'); const savedKey = localStorage.getItem('ge-ai-command-model'); return { qeValue: qeSel.value, statusText: statusEl ? statusEl.textContent : '', savedKey, }; }); assert.equal(evalResult2.qeValue, '101::sdxl', 'Changing gallery picker updates quick-edit picker in same tab'); assert.equal(evalResult2.savedKey, '101::sdxl', 'Changing gallery picker updates localStorage'); // --- Test 3: Same-tab synchronization (Quick Edit -> Gallery) --- const evalResult3 = await page.evaluate(async () => { const qeSel = document.getElementById('ge-ai-command-model'); qeSel.value = '202::sdxl'; qeSel.dispatchEvent(new Event('change')); await new Promise((r) => setTimeout(r, 40)); const gallerySel = document.getElementById('gallery-image-model'); const savedKey = localStorage.getItem('ge-ai-command-model'); return { galleryValue: gallerySel.value, savedKey, }; }); assert.equal(evalResult3.galleryValue, '202::sdxl', 'Changing quick-edit picker updates gallery picker in same tab'); assert.equal(evalResult3.savedKey, '202::sdxl', 'Changing quick-edit picker updates localStorage'); // --- Test 4: Cross-tab synchronization via storage event --- const evalResult4 = await page.evaluate(async () => { // Simulate storage event from another tab window.dispatchEvent(new StorageEvent('storage', { key: 'ge-ai-command-model', newValue: '101::sdxl', })); await new Promise((r) => setTimeout(r, 40)); const gallerySel = document.getElementById('gallery-image-model'); const qeSel = document.getElementById('ge-ai-command-model'); return { galleryValue: gallerySel.value, qeValue: qeSel.value, }; }); assert.equal(evalResult4.galleryValue, '101::sdxl', 'Storage event updates gallery picker'); assert.equal(evalResult4.qeValue, '101::sdxl', 'Storage event updates quick-edit picker'); } finally { await browser.close(); } }); test('picker reconstruction owns listeners and clears invalid persisted identities', async () => { const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext(); let imageRequests = 0; await context.route('http://picker-lifecycle.local/**', async route => { const path = new URL(route.request().url()).pathname; if (path === '/') return route.fulfill({ contentType: 'text/html', body: '
' }); if (path.startsWith('/api/')) { if (path.endsWith('/image-models')) imageRequests++; return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: path.endsWith('/image-models') ? [{ id: 'qwen-image', endpoint_id: 'alice', kind: 'edit', provider: 'A' }] : [], items: [], albums: [] }) }); } try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); } catch { return route.fulfill({ status: 404, body: '' }); } }); const page = await context.newPage(); await page.addInitScript(() => { window.modelListeners = {}; const add = window.addEventListener.bind(window); window.addEventListener = (type, ...args) => { if (['storage', 'ge-ai-command-model-change', 'ge:model-endpoints-updated'].includes(type)) window.modelListeners[type] = (window.modelListeners[type] || 0) + 1; return add(type, ...args); }; window.modelEvents = 0; add('ge-ai-command-model-change', () => window.modelEvents++); }); const init = async p => { await p.goto('http://picker-lifecycle.local/'); await p.evaluate(async () => { window.ai = await import('/static/js/editor/ai-models.js'); window.gallery = await import('/static/js/gallery.js'); window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') }); window.gallery.openGallery(); }); await p.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2); }; await init(page); const initial = await page.evaluate(() => ({ ...window.modelListeners })); for (let i = 0; i < 3; i++) { await page.evaluate(() => { window.gallery.closeGallery(); window.oldSelect = document.getElementById('ge-ai-command-model'); window.oldValue = window.oldSelect.value; document.getElementById('editor-container').replaceChildren(); }); await page.waitForFunction(() => !document.getElementById('gallery-modal')); await page.evaluate(() => { document.getElementById('editor-container').innerHTML = ''; window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') }); window.gallery.openGallery(); }); await page.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2); } assert.deepEqual(await page.evaluate(() => window.modelListeners), initial, 'no new global listeners after reopen/reconstruction'); const before = imageRequests; await page.evaluate(() => window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated'))); await page.waitForTimeout(100); assert.equal(imageRequests - before, 1, 'only live editor refreshes; no detached control handlers'); const p2 = await context.newPage(); await init(p2); await page.evaluate(() => { window.modelEvents = 0; window.footerUpdates = 0; new MutationObserver(records => window.footerUpdates += records.length).observe(document.getElementById('ge-status-model'), { childList: true }); const s = document.getElementById('ge-ai-command-model'); s.value = 'alice::qwen-image'; s.dispatchEvent(new Event('change')); }); await p2.waitForFunction(() => document.getElementById('gallery-image-model').value === 'alice::qwen-image'); assert.equal(await page.evaluate(() => window.modelEvents), 1); assert.equal(await page.evaluate(() => window.footerUpdates), 1, 'exactly one footer update'); assert.match(await page.locator('#ge-status-model').textContent(), /Edit:.*qwen-image/); assert.equal(await page.evaluate(() => window.oldSelect.value), await page.evaluate(() => window.oldValue), 'global events never update detached controls'); await init(p2); assert.equal(await p2.locator('#gallery-image-model').inputValue(), 'alice::qwen-image'); for (const invalid of ['deleted::qwen-image', 'disabled::qwen-image', 'https://private.invalid/v1::missing']) { await page.evaluate(value => localStorage.setItem('ge-ai-command-model', value), invalid); await init(page); assert.equal(await page.evaluate(() => localStorage.getItem('ge-ai-command-model')), null, invalid); assert.equal(await page.locator('#gallery-image-model').inputValue(), ''); assert.equal(await page.locator('#ge-ai-command-model').inputValue(), ''); } } finally { await browser.close(); } });