项目文件夹

文件
2026-07-13 13:12:33 +08:00

242 行
9.7 KiB
TypeScript

import { test, expect, type Page, type Route } from '@playwright/test'
const CONTROL_URL = '/control/'
const SESSION_KEY = 'agent:main:webchat:e2eartifactpreview'
// 1x1 transparent PNG, used as both the full image and the thumbnail bytes.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
const DEFAULT_ARTIFACTS = [
{
id: 'art-preview-img',
name: 'render.png',
mime: 'image/png',
size: 744448,
download_url: '/api/v1/artifacts/art-preview-img',
thumbnail_url: '/api/v1/artifacts/art-preview-img?variant=thumb',
},
]
// Seed one finished assistant turn carrying image artifacts with thumbnail
// variants, rewriting chat.history in flight.
async function seedHistory(page: Page, artifacts: object[] = DEFAULT_ARTIFACTS) {
await page.routeWebSocket(/\/ws$/, ws => {
const server = ws.connectToServer()
const historyIds = new Set<string>()
ws.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.type === 'req' && frame.method === 'chat.history') {
historyIds.add(String(frame.id))
}
} catch {}
server.send(message)
})
server.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.type === 'res' && frame.id !== undefined && historyIds.has(String(frame.id))) {
historyIds.delete(String(frame.id))
frame.ok = true
delete frame.error
frame.payload = {
messages: [
{
role: 'user',
text: 'Render an image.',
id: 'msg-preview-user',
timestamp: Math.floor(Date.now() / 1000) - 120,
},
{
role: 'assistant',
text: 'Here it is.',
id: 'msg-preview-assistant',
timestamp: Math.floor(Date.now() / 1000) - 60,
artifacts,
},
],
has_more: false,
}
ws.send(JSON.stringify(frame))
return
}
} catch {}
ws.send(message)
})
})
}
function fulfillPng(route: Route) {
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG_1x1 })
}
async function openSeeded(page: Page, artifacts?: object[]) {
await seedHistory(page, artifacts)
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(SESSION_KEY))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await page.waitForSelector('.chat-header', { timeout: 10000 })
}
test.describe('Artifact preview performance and slow-network states', () => {
test('thumbnail loads lazily and resolves to a media image', async ({ page }) => {
const requested: string[] = []
await page.route('**/api/v1/artifacts/**', route => {
requested.push(route.request().url())
return fulfillPng(route)
})
await openSeeded(page)
// The state machine reaches loaded and renders the thumbnail image.
await page.waitForSelector('.msg-media-card__img img', { timeout: 10000 })
// Lazy fetch targets the thumbnail variant, not the full image.
expect(requested.some(url => url.includes('art-preview-img') && url.includes('variant=thumb'))).toBe(true)
})
test('a slow link shows a loading state before the image', async ({ page }) => {
await page.route('**/api/v1/artifacts/**', async route => {
await new Promise(resolve => setTimeout(resolve, 1200))
return fulfillPng(route)
})
await openSeeded(page)
// While the fetch is in flight, the loading placeholder is visible and the
// image is not yet rendered.
await expect(page.locator('.msg-media-card__img--loading')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.msg-media-card__img img')).toHaveCount(0)
// It eventually resolves to the loaded image, never a permanent loading box.
await expect(page.locator('.msg-media-card__img img')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.msg-media-card__img--loading')).toHaveCount(0)
})
test('a failed preview shows Retry + Download and recovers on retry', async ({ page }) => {
let failNext = true
await page.route('**/api/v1/artifacts/**', route => {
if (failNext) {
failNext = false
return route.abort('failed')
}
return fulfillPng(route)
})
await openSeeded(page)
// The dead-end "loading forever" is replaced by an error card.
const errorCard = page.locator('.msg-media-card__img--error')
await expect(errorCard).toBeVisible({ timeout: 10000 })
await expect(errorCard).toHaveAttribute('data-state', 'error')
// Download is decoupled and present even while the preview failed.
await expect(errorCard.getByRole('button', { name: /Download render\.png/ })).toBeVisible()
// Retry re-fetches; the route now succeeds and the image resolves.
await errorCard.getByRole('button', { name: /Retry preview for render\.png/ }).click()
await expect(page.locator('.msg-media-card__img img')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.msg-media-card__img--error')).toHaveCount(0)
})
test('clicking the media image opens an in-app lightbox, not a download or new tab', async ({ page }) => {
let popupOpened = false
let downloadTriggered = false
page.on('popup', () => { popupOpened = true })
page.on('download', () => { downloadTriggered = true })
await page.route('**/api/v1/artifacts/**', route => fulfillPng(route))
await openSeeded(page)
// The thumbnail resolves, then the image button is the in-app trigger.
const imgButton = page.locator('.msg-media-card__img')
await expect(page.locator('.msg-media-card__img img')).toBeVisible({ timeout: 10000 })
await imgButton.click()
// A real modal dialog opens in-app: role=dialog + aria-modal, with a Download
// action in its footer. It is neither a popup nor a browser download.
const dialog = page.locator('.deliv-preview[role="dialog"]')
await expect(dialog).toBeVisible({ timeout: 10000 })
await expect(dialog).toHaveAttribute('aria-modal', 'true')
await expect(dialog.getByRole('button', { name: /Download/ })).toBeVisible()
// The lightbox shows the full image, never navigating away.
await expect(dialog.locator('.deliv-preview__image')).toBeVisible({ timeout: 10000 })
// Escape closes the lightbox and returns focus to the invoking image.
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(imgButton).toBeFocused()
expect(popupOpened).toBe(false)
expect(downloadTriggered).toBe(false)
})
test('the lightbox frame stays the same size when navigating between images', async ({ page }) => {
// Two SVGs whose declared intrinsic sizes differ wildly: a small explicit
// pixel size vs. a viewBox with no width/height at all. Before the fixed
// media frame, the shrink-wrapped panel resized to each image's intrinsic
// box, so prev/next made the whole dialog jump.
const svgSmall = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="280" viewBox="0 0 240 280">' +
'<rect width="240" height="280" fill="#c8d6f0"/></svg>',
)
const svgLarge = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900">' +
'<rect width="800" height="900" fill="#f0d6c8"/></svg>',
)
await page.route('**/api/v1/artifacts/**', route => {
const body = route.request().url().includes('art-nav-small') ? svgSmall : svgLarge
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body })
})
await openSeeded(page, [
{
id: 'art-nav-small',
name: 'small.svg',
mime: 'image/svg+xml',
size: 512,
download_url: '/api/v1/artifacts/art-nav-small',
thumbnail_url: '/api/v1/artifacts/art-nav-small?variant=thumb',
},
{
id: 'art-nav-large',
name: 'large.svg',
mime: 'image/svg+xml',
size: 512,
download_url: '/api/v1/artifacts/art-nav-large',
thumbnail_url: '/api/v1/artifacts/art-nav-large?variant=thumb',
},
])
await expect(page.locator('.msg-media-card__img img').first()).toBeVisible({ timeout: 10000 })
await page.locator('.msg-media-card__img').first().click()
const dialog = page.locator('.deliv-preview[role="dialog"]')
const image = dialog.locator('.deliv-preview__image')
await expect(image).toBeVisible({ timeout: 10000 })
const panel = dialog.locator('.deliv-preview__panel')
const before = await panel.boundingBox()
expect(before).not.toBeNull()
const srcBefore = await image.getAttribute('src')
await dialog.getByRole('button', { name: 'Next image' }).click()
await expect(image).toBeVisible({ timeout: 10000 })
// The full image is served as a fresh blob URL, so a changed src proves
// the second image actually rendered before we measure.
await expect.poll(() => image.getAttribute('src'), { timeout: 10000 }).not.toBe(srcBefore)
const after = await panel.boundingBox()
expect(after).not.toBeNull()
expect(Math.round(after!.width)).toBe(Math.round(before!.width))
expect(Math.round(after!.height)).toBe(Math.round(before!.height))
})
test('the caption Download control works regardless of preview state', async ({ page }) => {
await page.route('**/api/v1/artifacts/**', route => route.abort('failed'))
await openSeeded(page)
// Preview failed, but the caption-bar Download button is always available.
await expect(page.locator('.msg-media-card__img--error')).toBeVisible({ timeout: 10000 })
const downloadBtn = page.locator('.msg-media-card__download')
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toBeEnabled()
})
})