Files
photo-live/apps/viewer/e2e/embed-v2.spec.ts
T

342 lines
14 KiB
TypeScript
Raw Normal View History

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('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') });
});
});