import assert from 'node:assert/strict'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { after, before, describe, it } from 'node:test'; import type { Browser } from 'playwright-core'; import { launch, layout, requiredEnv, VIEWPORTS } from './harness.ts'; import { openItemPage } from './item-fixture.ts'; // 暂无预览只表示图片尚未生成,媒体仍复用测试服务提供的有效短片。 const itemId = Number(requiredEnv('PEACH_E2E_ITEM')); describe('缺预览时的统一补全入口', () => { let browser: Browser; before(async () => { browser = await launch() }); after(async () => { await browser?.close() }); for (const viewport of VIEWPORTS) { it(`${viewport.name}:菜单可用且不溢出,补全结束后卡面显示预览`, async () => { const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-card]' }); const { page } = opened; try { let complete = false; let started = false; let pendingStage = ''; const posts: Record[] = []; await page.route(url => url.pathname === '/api/items', route => route.fulfill({ json: { items: [{ id: itemId, name: '没有预览的本地影片.mp4', medium: 'video', location: 'local', has_thumb: complete, has_local_poster: complete, size: 12345, duration: 3600 }], total: 1, has_more: false, } })); await page.route(url => url.pathname === '/api/library-processing', async route => { if (route.request().method() === 'POST') { const command = route.request().postDataJSON(); posts.push(command); pendingStage = command.stage; started = true; return route.fulfill({ json: { status: 'running', job_id: 'preview-one', current_action: 'generating_preview' } }); } if (started) complete = true; return route.fulfill({ json: started ? { status: 'complete', job_id: 'preview-one', requested_stage: pendingStage, previews: 1 } : { status: 'idle' } }); }); await page.reload({ waitUntil: 'load' }); const prompt = page.locator('[data-library-completion]'); await prompt.waitFor(); assert.match(await prompt.innerText(), /1 部暂无预览/); const placement = await prompt.evaluate(element => { const text = element.querySelector('p')!; const link = text.querySelector('a')!; const action = element.querySelector('[data-note-action]')!; return { inline: link.parentElement === text, arrow: !!link.querySelector('svg'), text: text.getBoundingClientRect().toJSON(), action: action.getBoundingClientRect().toJSON() }; }); assert.ok(placement.inline && placement.arrow, JSON.stringify(placement)); if (viewport.mobile) { assert.ok(placement.action.top >= placement.text.bottom + 8, JSON.stringify(placement)); assert.ok(Math.abs(placement.action.left - placement.text.left) < 1, JSON.stringify(placement)); } else { assert.ok(placement.action.left >= placement.text.right + 8, `宽容器中按钮应位于说明右侧:${JSON.stringify(placement)}`); assert.ok(placement.action.top < placement.text.bottom, `宽容器中按钮不应另起一行:${JSON.stringify(placement)}`); const textCenter = placement.text.top + placement.text.height / 2; const actionCenter = placement.action.top + placement.action.height / 2; assert.ok(Math.abs(textCenter - actionCenter) <= 1, `说明文字应与按钮垂直居中:${JSON.stringify(placement)}`); } await prompt.evaluate(element => { element.style.width = '320px' }); const narrowText = await prompt.locator('p').boundingBox(); const narrowAction = await prompt.locator('[data-note-action]').boundingBox(); assert.ok(narrowText && narrowText.width >= 280, '窄容器中的提示正文应独占一行宽度'); assert.ok(narrowAction && narrowAction.y >= narrowText.y + narrowText.height + 8, '窄容器中按钮应退到说明下方'); await prompt.evaluate(element => { element.style.width = '' }); if (viewport.mobile) { const text = await prompt.locator('p').boundingBox(); assert.ok(text && text.width >= viewport.width - 80, '手机提示正文应独占一行宽度'); } const more = prompt.getByRole('button', { name: '更多同步方式' }); await more.focus(); await page.keyboard.press('Enter'); const menu = page.getByRole('dialog', { name: '同步方式' }); await menu.waitFor(); assert.deepEqual(await menu.getByRole('button').locator(':scope > span > span:first-child').allTextContents(), ['仅补全影片资料', '仅补全预览图']); const box = await menu.boundingBox(); assert.ok(box && box.x >= 0 && box.x + box.width <= viewport.width); await page.keyboard.press('Escape'); await menu.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === '更多同步方式', null, { timeout: 5_000 }); for (const dark of [false, true]) { await page.evaluate(value => { document.documentElement.dataset.theme = value ? 'dark' : 'light'; document.documentElement.classList.toggle('dark', value); }, dark); await more.click(); await menu.waitFor(); const measured = await layout(page); assert.ok(measured.scrollWidth <= measured.viewportWidth, JSON.stringify(measured)); assert.deepEqual(measured.offenders, []); const artifacts = path.resolve('..', 'build', 'agent-verification', 'library-completion'); await mkdir(artifacts, { recursive: true }); await page.screenshot({ path: path.join(artifacts, `${viewport.name}-${dark ? 'dark' : 'light'}.png`) }); await page.keyboard.press('Escape'); await menu.waitFor({ state: 'hidden' }); } await more.click(); await menu.getByRole('button', { name: /^仅补全预览图/ }).click(); await prompt.waitFor({ state: 'hidden' }); await page.locator('[data-media-card] img.poster').waitFor(); assert.deepEqual(posts, [{ stage: 'previews', asset_ids: [itemId] }]); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); } });