import { createServer, type Server } from 'node:http'; import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test, type Page } from '@playwright/test'; import { cleanupSeededAlbums, createEmbedSlot, flushOutbox, flushSync, seedAlbum, seedPhoto, updateEmbedSlot, } from './helpers'; // UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ← // viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展: // E01 出口门 = 固定 195×280 小窗跨源可见且无 footer;E02 = 静态高保真测量。 // 照片种子为真实摄影样本(e2e/fixtures,picsum 固定 seed 下载——验收 §11.1 // 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。 const HOST_PORT = 3999; const VIEWER_ORIGIN = 'http://127.0.0.1:5175'; const MINUTE = 60_000; const HERE = dirname(fileURLToPath(import.meta.url)); const OUT_ROOT = join(HERE, '../../../docs/designs/ui04-implementation'); /** 摄影样本(picsum seed 固定):[file, width, height]。 */ const FIXTURES: Array<[string, number, number]> = [ ['photo-01.jpg', 800, 500], ['photo-02.jpg', 800, 1100], ['photo-03.jpg', 800, 800], ['photo-04.jpg', 1200, 400], ['photo-05.jpg', 800, 1400], ['photo-06.jpg', 900, 600], ['photo-07.jpg', 800, 1000], ['photo-08.jpg', 1000, 700], ]; let slotToken = ''; async function seedFeed(alias: string): Promise { const album = await seedAlbum({ alias, defaultLocale: 'zh-CN', translations: [ { locale: 'zh-CN', title: `${alias} 中文相册` }, { locale: 'en', title: `${alias} Album` }, ], }); for (let i = 0; i < FIXTURES.length; i++) { const [file, width, height] = FIXTURES[i]; const bytes = await readFile(join(HERE, 'fixtures', file)); await seedPhoto(album.id, `${alias}-p${i + 1}`, { publishedAt: new Date(Date.now() - (FIXTURES.length - i) * MINUTE), width, height, thumbBytes: bytes, browseBytes: bytes, }); } await flushSync(album.id); const slot = await createEmbedSlot(`ui04-v2-${alias}`); // v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡) await updateEmbedSlot(slot.id, { albumId: album.id, settings: { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, displayMode: 'vertical-stream', scrollSpeed: 16, reactionsEnabled: true, }, }); await flushOutbox(); return slot.token; } let currentHtml = ''; const server: Server = createServer((_req, res) => { res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(currentHtml); }); async function openHost(page: Page, viewport: { width: number; height: number }): Promise { await page.setViewportSize(viewport); currentHtml = ` UI-04 v2 host

Host page (v2 stream)

`; await page.goto(`http://127.0.0.1:${HOST_PORT}`); } test.beforeAll(async () => { slotToken = await seedFeed('ui04v2'); await new Promise((resolve) => { // 残留进程容错:上轮失败运行的夹具服务器可能仍占端口——同内容服务, // 复用即可(其后用例改写 currentHtml 对两个进程同值)。 server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') resolve(); else throw err; }); server.listen(HOST_PORT, '127.0.0.1', () => resolve()); }); }); test.afterAll(async () => { await cleanupSeededAlbums(); server.close(); }); test.describe.serial('E01 exit gate: fixed window, no footer', () => { for (const [name, viewport] of [ ['desktop 1440x900', { width: 1440, height: 900 }], ['mobile 390x844', { width: 390, height: 844 }], ] as const) { test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => { const mobile = viewport.width < 480; await openHost(page, viewport); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); const wrapper = frame.locator('..'); // 手机默认收起(沿用 v1 策略):点击把手展开后测量。 if (mobile) { await page.locator('[role="button"]').first().click(); } await expect(frame).toBeVisible(); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) .toBe(195); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) .toBe(280); // iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。 const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible(); // v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部 // scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。 await expect(inside.locator('.embed-head')).toHaveCount(0); await expect(inside.locator('.embed-footer')).toHaveCount(0); await expect(inside.getByText('查看相册')).toHaveCount(0); await expect(inside.getByText('照片轮播')).toHaveCount(0); await expect(inside.locator('.stream-viewport')).toHaveCount(1); }); } }); test.describe.serial('E02 static fidelity: measured styles + screenshots', () => { test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..'); await expect(page.locator('iframe[src*="/embed/"][src*="v=2"]')).toBeVisible(); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) .toBe(195); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) .toBe(280); // 外壳视觉规格(§2.1):圆角 12 / 单圈 1px 白边 / 底色 #10161E / 阴影。 const shell = await wrapper.evaluate((el) => { const cs = getComputedStyle(el); return { radius: cs.borderTopLeftRadius, borderWidth: cs.borderTopWidth, borderColor: cs.borderTopColor, background: cs.backgroundColor, boxShadow: cs.boxShadow, boxSizing: cs.boxSizing, }; }); expect(shell.radius).toBe('12px'); expect(shell.borderWidth).toBe('1px'); expect(shell.borderColor).toBe('rgba(255, 255, 255, 0.8)'); expect(shell.background).toBe('rgb(16, 22, 30)'); expect(shell.boxSizing).toBe('border-box'); // 照片带(§2.2/2.3):原比例、宽满内宽(195-2=193)、3px 缝。 const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); const firstImg = inside.locator('.stream-photo__img').first(); await expect(firstImg).toBeVisible(); const imgBox = await firstImg.boundingBox(); expect(Math.round(imgBox?.width ?? 0)).toBe(193); // 首图 = 最新照片(publishedAt 倒序)= photo-08 1000×700 → 193*0.7 ≈ 135。 expect(Math.round(imgBox?.height ?? 0)).toBe(135); const gap = await inside.locator('.stream-track').evaluate((el) => getComputedStyle(el).rowGap); expect(gap).toBe('3px'); // 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44。 const collapse = inside.locator('.stream-collapse'); const cbBox = await collapse.boundingBox(); expect(Math.round(cbBox?.width ?? 0)).toBe(44); expect(Math.round(cbBox?.height ?? 0)).toBe(44); const vpBox = await inside.locator('.stream-viewport').boundingBox(); expect(Math.round((vpBox?.x ?? 0) + (vpBox?.width ?? 0) - (cbBox?.x ?? 0) - 44)).toBe(0); expect(Math.round((cbBox?.y ?? 0) - (vpBox?.y ?? 0))).toBe(0); // 表面无工具元素(§2.2):除收起外无其他 button/链接按钮形态。 await expect(inside.locator('button')).toHaveCount(1); await page.screenshot({ path: join(OUT_ROOT, 'actual/desktop/e02-static-desktop.png') }); }); test('mobile 390x844: expanded window identical 195x280 + screenshot', async ({ page }) => { await openHost(page, { width: 390, height: 844 }); await page.locator('[role="button"]').first().click(); const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..'); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) .toBe(195); await expect .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) .toBe(280); await page.screenshot({ path: join(OUT_ROOT, 'actual/mobile/e02-static-mobile.png') }); }); });