Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 115 additions & 4 deletions e2e/queue-sidebar.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,22 +159,133 @@ test.describe('Queue Sidebar E2E', () => {
await waitForQueueEmpty(page)
})

// ── #5 — Running card preview reflects live execution previews ────
test('running card preview shows img element during execution', async ({ page }) => {
const wf = await getModifiedWorkflow(page)
test.skip(!wf, 'No workflow available')

await queuePrompt(page, wf)

// Wait for a running card to appear
await expect(page.locator('[data-status="running"]').first())
.toBeAttached({ timeout: 10_000 })

// Poll until we observe a running card with an <img> element, OR the
// task finishes. Using a manual loop avoids a race where the card
// transitions to completed between the two separate evaluate() calls
// that toPass() would make.
let sawRunningWithImg = false
const pollStart = Date.now()
while (Date.now() - pollStart < 60_000) {
const result = await page.evaluate(() => {
const card = document.querySelector('[data-status="running"]')
if (!card) return 'completed'
return card.querySelector('.task-preview img') ? 'has-img' : 'no-img'
})
if (result === 'has-img') { sawRunningWithImg = true; break }
if (result === 'completed') break
await page.waitForTimeout(200)
}

expect(sawRunningWithImg).toBe(true)
await waitForQueueEmpty(page)
})

// ── #7 — Completed card shows preview; persists after reload ───────
test('completed card shows preview image and persists after page reload', async ({ page }) => {
const wf = await getModifiedWorkflow(page)
test.skip(!wf, 'No workflow available')

await queuePrompt(page, wf)
await waitForQueueEmpty(page)
await page.waitForTimeout(1500) // let render cycle complete

// Completed card must have an <img> whose src contains /view?filename=
const completedCard = page.locator('[data-status="completed"]').first()
await expect(completedCard).toBeAttached({ timeout: 10_000 })

// Wait for the img to be present before reading its src
const imgLocator = completedCard.locator('.task-preview img').first()
await expect(imgLocator).toBeAttached({ timeout: 5_000 })
const imgSrc = await imgLocator.getAttribute('src')
expect(imgSrc).toMatch(/\/view\?/)

// Extract filename to verify it's the same image after reload (cache hit)
const beforeFilename = new URLSearchParams(imgSrc.split('?')[1]).get('filename')
expect(beforeFilename).toBeTruthy()

// Reload and confirm the same card still shows the same image (localStorage cache hit)
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(2000)
await openSidebar(page)

const reloadedImg = page.locator('[data-status="completed"] .task-preview img').first()
await expect(reloadedImg).toBeAttached({ timeout: 5_000 })
const reloadedSrc = await reloadedImg.getAttribute('src')
expect(reloadedSrc).toMatch(/\/view\?/)

// Same filename proves the preview came from localStorage cache, not a random re-fetch
const afterFilename = new URLSearchParams(reloadedSrc.split('?')[1]).get('filename')
expect(afterFilename).toBe(beforeFilename)
})

// ── #8 — localStorage cache is populated after execution ───────────
test('localStorage cache is populated with output after execution', async ({ page }) => {
const wf = await getModifiedWorkflow(page)
test.skip(!wf, 'No workflow available')

const queueResult = await queuePrompt(page, wf)
const promptId = queueResult?.prompt_id
test.skip(!promptId, 'Queue response did not include prompt_id')

await waitForQueueEmpty(page)
await page.waitForTimeout(1000)

const cacheEntry = await page.evaluate((pid) => {
try {
const raw = localStorage.getItem('queueSidebar.lastOutput')
if (!raw) return null
const cache = JSON.parse(raw)
return cache[pid] ?? null
} catch {
return null
}
}, promptId)

expect(cacheEntry).not.toBeNull()
expect(typeof cacheEntry.filename).toBe('string')
expect(cacheEntry.filename.length).toBeGreaterThan(0)
})

// ── #6 — No [QueueSidebar] console.warn during normal operation ────
test('no QueueSidebar console warnings during normal operation', async ({ page }) => {
const warnings = []
page.on('console', msg => {
if (msg.type() === 'warning' && msg.text().includes('[QueueSidebar]')) {
warnings.push(msg.text())
// Intercept console.warn at the JS level BEFORE page load so we capture
// warnings emitted during extension initialisation (i18n fetch failures,
// setup errors, etc.) — not just warnings emitted after test setup.
await page.addInitScript(() => {
window.__qsWarnings = []
const origWarn = console.warn.bind(console)
console.warn = (...args) => {
const msg = args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')
if (msg.includes('[QueueSidebar]')) window.__qsWarnings.push(msg)
origWarn(...args)
}
})

// Navigate fresh so the init script runs before any extension code executes
await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 })
await page.waitForTimeout(2000) // let extensions load
await openSidebar(page)

const wf = await getModifiedWorkflow(page)
if (wf) {
await queuePrompt(page, wf)
await waitForQueueEmpty(page)
}

await page.waitForTimeout(1000)

const warnings = await page.evaluate(() => window.__qsWarnings)
expect(warnings).toEqual([])
})
})
126 changes: 126 additions & 0 deletions tests/outputCache.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
saveOutputCache,
loadOutputCache,
firstOutput,
OUTPUT_CACHE_KEY,
OUTPUT_CACHE_MAX,
} from '../web/lib/outputCache.js'

// clear localStorage before every test to ensure isolation across all describe blocks
beforeEach(() => localStorage.clear())

describe('loadOutputCache', () => {
it('returns null for unknown promptId', () => {
expect(loadOutputCache('nonexistent')).toBeNull()
})

it('returns null when localStorage value is corrupt JSON', () => {
localStorage.setItem(OUTPUT_CACHE_KEY, 'not-valid-json{{{')
expect(loadOutputCache('any')).toBeNull()
})

it('returns null for null or undefined promptId', () => {
expect(loadOutputCache(null)).toBeNull()
expect(loadOutputCache(undefined)).toBeNull()
})
})

describe('saveOutputCache', () => {
it('round-trips an output object', () => {
const output = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
saveOutputCache('prompt-1', output)
expect(loadOutputCache('prompt-1')).toEqual(output)
})

it('overwrites existing entry for the same promptId', () => {
const first = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
const second = { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] }
saveOutputCache('prompt-1', first)
saveOutputCache('prompt-1', second)
expect(loadOutputCache('prompt-1')).toEqual(second)
})

it('returns null for a different promptId', () => {
saveOutputCache('prompt-1', { images: [{ filename: 'a.png' }] })
expect(loadOutputCache('prompt-2')).toBeNull()
})

it('does not write to cache for falsy promptId', () => {
saveOutputCache(null, { filename: 'img.png', subfolder: '', type: 'output' })
saveOutputCache(undefined, { filename: 'img.png', subfolder: '', type: 'output' })
// cache 應維持空白
const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
expect(raw).toBeNull()
})

it('evicts the oldest entry when exceeding OUTPUT_CACHE_MAX', () => {
for (let i = 0; i < OUTPUT_CACHE_MAX; i++) {
saveOutputCache(`p-${i}`, { filename: `img-${i}.png`, subfolder: '', type: 'temp' })
}
saveOutputCache('p-overflow', { filename: 'overflow.png', subfolder: '', type: 'temp' })

expect(loadOutputCache('p-0')).toBeNull() // 被踢出
expect(loadOutputCache('p-1')).not.toBeNull() // 仍保留
expect(loadOutputCache(`p-${OUTPUT_CACHE_MAX - 1}`)).not.toBeNull() // 仍保留
expect(loadOutputCache('p-overflow')).not.toBeNull() // 新增的存在
})
})

describe('firstOutput', () => {
it('returns null when outputs is empty', () => {
expect(firstOutput({}, 'prompt-1')).toBeNull()
})

it('returns the first image found in dict order (no cache)', () => {
const outputs = {
nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
nodeB: { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] },
}
expect(firstOutput(outputs, 'no-cache')).toEqual({ filename: 'a.png', subfolder: '', type: 'output' })
})

it('returns cached output when promptId matches, ignoring dict order', () => {
const cached = { filename: 'cached.png', subfolder: 'sub', type: 'output' }
saveOutputCache('prompt-cached', cached)
const outputs = {
nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
}
expect(firstOutput(outputs, 'prompt-cached')).toEqual(cached)
})

it('falls back to dict iteration when cache misses', () => {
const outputs = {
nodeA: { images: [{ filename: 'fallback.png', subfolder: '', type: 'output' }] },
}
expect(firstOutput(outputs, 'cache-miss')).toEqual({ filename: 'fallback.png', subfolder: '', type: 'output' })
})

it('returns null when outputs contains only empty arrays', () => {
const outputs = {
nodeA: { images: [] },
nodeB: { gifs: [] },
}
expect(firstOutput(outputs, 'prompt-empty')).toBeNull()
})

it.each([
['gifs', { gifs: [{ filename: 'anim.gif', subfolder: '', type: 'output' }] }, { filename: 'anim.gif', subfolder: '', type: 'output' }],
['video', { video: { filename: 'clip.mp4', subfolder: '', type: 'output' } }, { filename: 'clip.mp4', subfolder: '', type: 'output' }],
['audio', { audio: { filename: 'sound.wav', subfolder: '', type: 'output' } }, { filename: 'sound.wav', subfolder: '', type: 'output' }],
])('returns item for %s key', (_key, nodeOut, expected) => {
expect(firstOutput({ n: nodeOut }, undefined)).toEqual(expected)
})

it('prefers images key over later keys in the same node', () => {
const imgItem = { filename: 'img.png', subfolder: '', type: 'output' }
const gifItem = { filename: 'anim.gif', subfolder: '', type: 'output' }
const outputs = {
nodeA: {
images: [imgItem],
gifs: [gifItem],
},
}
expect(firstOutput(outputs, 'prompt-prefer')).toEqual(imgItem)
})
})
Loading
Loading