import { createServer, type Server } from 'node:http'; import { readFile, rename } 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('E03 continuous scroll: speed, boundary, DOM cap', () => { /** 读 iframe 内轨道 transform 的 translateY 像素值。 */ async function trackOffset(page: Page): Promise { const embedFrame = page.frames().find((f) => f.url().includes('v=2')); expect(embedFrame).toBeDefined(); return (await embedFrame!.locator('.stream-track').evaluate((el) => { const m = /translateY\((-?[\d.]+)px\)/.exec(el.style.transform ?? ''); return m === null ? 0 : Number.parseFloat(m[1]); })) as number; } /** 可见照片元素(src+top)快照:连续性匹配用。 */ async function visibleImgs(page: Page): Promise> { const embedFrame = page.frames().find((f) => f.url().includes('v=2')); expect(embedFrame).toBeDefined(); return (await embedFrame!.locator('.stream-track').evaluate((el) => Array.from(el.querySelectorAll('.stream-photo__img')).map((img) => ({ src: img.currentSrc.split('/').pop() ?? img.currentSrc, top: img.getBoundingClientRect().top, })), )) as Array<{ src: string; top: number }>; } test('default 16px/s: anchor photo moves up ~32px over 2s (±2px, §11.2)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); await page.waitForTimeout(1200); // 首帧静置 + 稳定 const before = await trackOffset(page); await page.waitForTimeout(2000); const after = await trackOffset(page); // 无退役区间(首条 123.6px > 32px 位移)→ transform 差即位移(越滚越负)。 const moved = before - after; expect(moved).toBeGreaterThanOrEqual(30); expect(moved).toBeLessThanOrEqual(34); }); test('loop boundary: matched photo keeps -16px/s continuity, no jump >2px', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); await page.waitForTimeout(800); // 12s 采样(每 250ms):覆盖 ≥1 个退役边界(条高 123.6px / 16px/s ≈ 7.7s)。 const samples: Array> = []; const deadline = Date.now() + 12_000; while (Date.now() < deadline) { samples.push(await visibleImgs(page)); await page.waitForTimeout(250); } expect(samples.length).toBeGreaterThan(30); let matched = 0; let maxError = 0; for (let i = 1; i < samples.length; i++) { const prev = new Map(samples[i - 1].map((s) => [s.src, s.top])); for (const cur of samples[i]) { const before = prev.get(cur.src); if (before === undefined) continue; matched += 1; const expected = -16 * 0.25; // 采样间隔约 250ms const error = Math.abs(cur.top - before - expected); maxError = Math.max(maxError, error); break; // 每对样本取一个匹配元素即可 } } // 匹配连续性成立且误差远小于 2px 口径(定时器抖动计入)。 expect(matched).toBeGreaterThan(20); expect(maxError).toBeLessThan(2.5); }); test('DOM instances bounded (≤16) over 20s (§11.2 resource cap)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); await page.waitForTimeout(1000); const embedFrame = page.frames().find((f) => f.url().includes('v=2')); expect(embedFrame).toBeDefined(); const counts: number[] = []; const deadline = Date.now() + 20_000; while (Date.now() < deadline) { counts.push(await embedFrame!.locator('.stream-photo__img').count()); await page.waitForTimeout(1000); } expect(Math.max(...counts)).toBeLessThanOrEqual(16); }); }); test.describe.serial('E03 recording: 30s+ including loop boundary', () => { test('record 32s scroll video into recordings/', async ({ browser }) => { test.setTimeout(120_000); // 录屏 32s + 启动裕量(配置缺省 30s 会截断) const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, recordVideo: { dir: join(OUT_ROOT, 'recordings'), size: { width: 1440, height: 900 } }, }); const page = await context.newPage(); try { currentHtml = ` UI-04 v2 recording host

Host page (v2 recording)

`; await page.goto(`http://127.0.0.1:${HOST_PORT}`); await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor(); // 32s:条高 ~123.6px / 16px/s ≈ 7.7s 一退役 → 至少 4 个循环边界。 await page.waitForTimeout(32_000); } finally { const video = page.video(); await context.close().catch(() => undefined); if (video !== null) { const src = await video.path(); if (src !== null) { await rename(src, join(OUT_ROOT, 'recordings', 'e03-scroll-32s.webm')).catch(() => undefined); } } } }); }); test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', () => { async function trackTransform(page: Page): Promise { const embedFrame = page.frames().find((f) => f.url().includes('v=2')); expect(embedFrame).toBeDefined(); return (await embedFrame!.locator('.stream-track').evaluate((el) => { const m = /translateY\((-?[\d.]+)px\)/.exec(el.style.transform ?? ''); return m === null ? 0 : Number.parseFloat(m[1]); })) as number; } test('fine-pointer hover pauses; leaving resumes after 500ms (§4.2)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); await page.waitForTimeout(1500); // 悬停 iframe 中心(细指针环境):300ms 后 transform 冻结。 const box = await frame.boundingBox(); expect(box).not.toBeNull(); await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); await page.waitForTimeout(400); const pausedAt = await trackTransform(page); await page.waitForTimeout(800); expect(await trackTransform(page)).toBe(pausedAt); // 悬停期间静止 // 离开:500ms 恢复延时后继续上滚。 await page.mouse.move(40, 40); await page.waitForTimeout(400); const resumedAt = await trackTransform(page); await page.waitForTimeout(900); const after = await trackTransform(page); expect(after).toBeLessThan(resumedAt); // 恢复滚动(translateY 更负) }); test('photo click opens viewer deep link for the clicked photo (§4.1)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); // 先悬停冻结(避免点击瞬间目标位移——点击锁定的正是防错图语义)。 const box = await frame.boundingBox(); await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); await page.waitForTimeout(400); const target = inside.locator('a.stream-photo').first(); const photoId = await target.getAttribute('data-photo-id'); expect(photoId).toBeTruthy(); const popupPromise = page.waitForEvent('popup', { timeout: 5000 }); await target.click(); const popup = await popupPromise; expect(popup.url()).toContain(`/p/${photoId}`); await popup.close(); }); test('drag beyond 8px cancels click (§4.1)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); const box = await frame.boundingBox(); expect(box).not.toBeNull(); const cx = (box!.x ?? 0) + 90; const cy = (box!.y ?? 0) + 200; let popupOpened = false; page.on('popup', () => { popupOpened = true; }); await page.mouse.move(cx, cy); await page.waitForTimeout(300); await page.mouse.down(); await page.mouse.move(cx, cy - 30, { steps: 5 }); // 拖动 30px > 8px await page.mouse.up(); await page.waitForTimeout(800); expect(popupOpened).toBe(false); }); test('keyboard: Space toggles pause (no auto-resume), Escape collapses (§4.2)', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); await page.waitForTimeout(1200); // 聚焦流区域(点击视口非链接处不触发照片点击——顶部渐隐带命中视口)。 const box = await frame.boundingBox(); await page.mouse.click((box!.x ?? 0) + 90, (box!.y ?? 0) + 6); await inside.locator('.stream-viewport').focus(); await page.keyboard.press(' '); await page.waitForTimeout(300); const pausedAt = await trackTransform(page); await page.waitForTimeout(1000); expect(await trackTransform(page)).toBe(pausedAt); // Space 暂停:不自动恢复 // Escape → collapse 上行宿主:wrapper 隐藏 + 把手出现。 await page.keyboard.press('Escape'); await expect(page.locator('[role="button"]').first()).toBeVisible(); await expect(frame).toBeHidden(); }); test('slot disable → destroy: wrapper and handle removed, no ghost (§6.3)', async ({ page }) => { // 独立槽(不破坏主 slot 的后续用例)。 const album = await seedAlbum({ alias: 'ui04v2-destroy', defaultLocale: 'zh-CN', translations: [{ locale: 'zh-CN', title: '销毁测试' }], }); await seedPhoto(album.id, 'ui04v2-destroy-p1', { publishedAt: new Date() }); await flushSync(album.id); const slot = await createEmbedSlot('ui04-v2-destroy'); 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(); currentHtml = ` destroy host `; await page.setViewportSize({ width: 800, height: 600 }); await page.goto(`http://127.0.0.1:${HOST_PORT}`); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); // 停用:管理面 enabled=false → 高优先级 slot.updated → ov 404 → destroy。 await updateEmbedSlot(slot.id, { enabled: false }); await flushOutbox(); // dev 构建 data-poll=1000 → 1s 轮询加速(loader 仅显式透传)。 await expect(frame).toBeHidden({ timeout: 15_000 }); await expect(page.locator('[role="button"]')).toHaveCount(0); }); }); 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') }); }); });