Files
photo-live/apps/viewer/e2e/embed-v2.spec.ts
T
浅念 3912279889 feat(viewer,ui04): E04 interactions — pause reason set, click lock/drag cancel, manual scrub, keyboard semantics, destroy
- pauseReasons Set (hover/focus/pointer/offscreen/hidden/manual + userPaused via Space, no auto-resume); 500ms transient resume with re-evaluation
- pointerdown target lock; >8px drag suppresses click and scrubs via nudgeOffset (window-clamped); removed photos clear pending target
- keyboard: Space pause toggle, Escape collapse, ArrowUp/Down scrub; input fields untouched
- aria-live status announcements (zh/en); viewport touch-action none
- e2e: hover freeze/resume, popup deep link hit, drag cancel, keyboard, slot disable destroy

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 13:28:13 +08:00

472 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
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<void> {
await page.setViewportSize(viewport);
currentHtml = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UI-04 v2 host</title></head>
<body style="min-height: 1200px; font-family: system-ui">
<h1>Host page (v2 stream)</h1>
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
</body>
</html>`;
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
}
test.beforeAll(async () => {
slotToken = await seedFeed('ui04v2');
await new Promise<void>((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<number> {
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<Array<{ src: string; top: number }>> {
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<HTMLImageElement>('.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<Array<{ src: string; top: number }>> = [];
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 = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<title>UI-04 v2 recording host</title></head>
<body style="min-height: 1200px">
<h1>Host page (v2 recording)</h1>
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
</body></html>`;
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<number> {
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 = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<title>destroy host</title></head><body style="min-height: 600px">
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slot.token}" data-poll="1000" defer></script>
</body></html>`;
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') });
});
});