Files
photo-live/apps/viewer/e2e/embed-v2.spec.ts
T
浅念 5a207b395e feat(app,viewer): WeChat card share + 'Share to...' label + site og tags
- 按钮文字「分享到微信/其他应用」→「分享到...」(用户需求)
- 微信卡片分享(fluwx 6):WECHAT_APPID 经 --dart-define 注入;已配置且
  装微信 → WXWebpage 卡片(标题=相册名、描述=日期·地点、缩略图=封面压缩
  240px PNG ≤32KB、点击进观众链接);未配置/未装 → 回退系统分享面板
- viewer index.html 加站点级 og 标签(微信/QQ 抓取链接预览用;每相册动态
  og 需服务端注入,登记后续增强)
- 397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:05:53 +08:00

898 lines
41 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 = 3998; // 独立于 embed-crossorigin(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 = '';
let albumToken = '';
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` },
],
});
albumToken = album.publicToken;
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}`);
}
// 文件级 serial:config fullyParallel 会把同文件用例分散到多 worker,各自
// 执行 beforeAll(种子/夹具服务器/admin 幂等种子并发冲突)。整文件单 worker
// 串行,beforeAll 恰好一次。
test.describe.configure({ mode: 'serial' });
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();
// 宿主内弹框(用户决策 2026-09-23):不新开标签,宿主页覆盖层 + 详情 iframe。
let popupOpened = false;
page.once('popup', () => {
popupOpened = true;
});
await target.click();
const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]');
await modalFrame.waitFor({ timeout: 5000 });
expect(popupOpened).toBe(false);
await expect
.poll(async () => {
const f = page.frames().find((fr) => (fr.url().includes('/p/') ?? false));
return f?.url() ?? '';
})
.toContain(`/p/${photoId}`);
await page.screenshot({ path: `${OUT_ROOT}/actual/server/photo-modal.png` });
await page.keyboard.press('Escape');
await expect(modalFrame).toHaveCount(0);
});
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('E05 realtime hearts: real burst drives particles', () => {
test('real like from another context spawns heart on the liked photo (EMB-A09)', 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(500);
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
expect(embedFrame).toBeDefined();
const photoId = await embedFrame!.locator('a.stream-photo').first().getAttribute('data-photo-id');
expect(photoId).toBeTruthy();
// 真实公开路径点赞(ov 域):服务端广播 likes.burst → 订阅出泡。
const like = await page.request.post(
`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
{
headers: { 'content-type': 'application/json' },
data: { sessionId: `ui04-e2e-${Date.now()}`, batchId: `batch-${Date.now()}`, delta: 1 },
},
);
expect(like.status()).toBeLessThan(300);
// 心形在 1.6s 动画内出现于 iframe。
await expect(inside.locator('.heart').first()).toBeVisible({ timeout: 5_000 });
// 归属正确:心形 top 落在被赞照片交集附近(±80px 容差:起点为交集下侧-10)。
const heartTop = await inside.locator('.heart').first().boundingBox();
const photoRect = await embedFrame!
.locator(`a.stream-photo[data-photo-id="${photoId}"]`)
.first()
.boundingBox();
expect(heartTop).not.toBeNull();
expect(photoRect).not.toBeNull();
const frameOriginY = (await frame.boundingBox())!.y ?? 0;
// 统一 frame 坐标:bbox 均为页面坐标,减 frame 顶。期望起点 = 照片交集
// 下侧 -10(origin),心形自 origin 上浮 ≤110px(动画进行中的实测位置
// 落在 [origin-130, origin+5] 区间)。
const heartFrame = heartTop!.y - frameOriginY;
const originFrame = photoRect!.y + photoRect!.height - 10 - frameOriginY;
expect(heartFrame).toBeLessThanOrEqual(originFrame + 5);
expect(heartFrame).toBeGreaterThanOrEqual(originFrame - 130);
});
test('embed itself submits no likes or visits (EMB-A15)', async ({ page }) => {
const interactive: string[] = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('/likes') || url.includes('/visits')) {
if (req.method() === 'POST') interactive.push(url);
}
});
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();
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
await page.mouse.wheel(0, 40);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(2_500);
expect(interactive).toEqual([]);
});
});
test.describe.serial('E05 recording: scroll + real like hearts', () => {
test('record 16s scroll with periodic real likes into recordings/', async ({ browser }) => {
test.setTimeout(90_000);
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 hearts recording host</title></head>
<body style="min-height: 1200px">
<h1>Host page (v2 hearts 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}`);
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
await page.waitForTimeout(2_000);
// 每 2.4s 真实点赞一个可见照片(效果示例 simulate 节奏)。
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
for (let i = 0; i < 6; i++) {
const photoId =
(await embedFrame!.locator('a.stream-photo').nth(i % 2).getAttribute('data-photo-id')) ?? '';
if (photoId !== '') {
await page.request
.post(`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, {
headers: { 'content-type': 'application/json' },
data: {
sessionId: `ui04-rec-${Date.now()}`,
batchId: `batch-rec-${Date.now()}-${i}`,
delta: 1,
},
})
.catch(() => undefined);
}
await page.waitForTimeout(2_400);
}
} 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', 'e05-hearts-16s.webm')).catch(() => undefined);
}
}
}
});
});
test.describe.serial('E07 acceptance matrix additions', () => {
/** 便捷:开宿主取 wrapper/iframe/inside。 */
async function open(page: Page, viewport: { width: number; height: number }) {
await openHost(page, viewport);
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
// 窄屏默认收起:iframe 可能 display:none——attached 即返回(展开由调用方处理)。
await frame.waitFor({ state: 'attached' });
return {
frame,
wrapper: frame.locator('..'),
inside: page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'),
};
}
test('EMB-A01: 320/430/768/1920 viewports all fixed 195x280', async ({ page }) => {
for (const viewport of [
{ width: 320, height: 568 },
{ width: 430, height: 932 },
{ width: 768, height: 1024 },
{ width: 1920, height: 1080 },
]) {
const { wrapper, frame } = await open(page, viewport);
if (viewport.width < 480) {
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);
}
});
test('EMB-A02: narrow viewport shrinks (min(195, avail)); two slots stack 12px apart', async ({ page }) => {
// 狭窄视口:offsetX 16 + 边距 → 可用宽 < 195 → 收缩且不溢出。
await page.setViewportSize({ width: 180, height: 600 });
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<title>narrow</title></head><body>
<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('[role="button"]').first().click(); // 窄屏默认收起 → 展开
const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..');
const width = Math.round((await wrapper.boundingBox())?.width ?? 0);
expect(width).toBeLessThanOrEqual(195);
expect(width).toBeGreaterThanOrEqual(120);
const right = (await wrapper.boundingBox())?.x ?? 0;
expect(right + width).toBeLessThanOrEqual(180 + 1);
// 多槽同侧:第二个 v2 槽纵排 12px 间距、不重叠(EMB-A02)。
const album = await seedAlbum({
alias: 'ui04v2-stack',
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: '堆叠相册' }],
});
await seedPhoto(album.id, 'ui04v2-stack-p1', { publishedAt: new Date() });
await seedPhoto(album.id, 'ui04v2-stack-p2', { publishedAt: new Date(Date.now() - MINUTE) });
await flushSync(album.id);
const slot2 = await createEmbedSlot('ui04-v2-stack');
await updateEmbedSlot(slot2.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();
await page.setViewportSize({ width: 1440, height: 900 });
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /></head>
<body style="min-height: 1200px">
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slot2.token}" defer></script>
</body></html>`;
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
const frames = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await expect(frames).toHaveCount(2, { timeout: 10_000 });
const boxes = (await frames.evaluateAll((els) =>
els.map((el) => {
const r = el.parentElement?.getBoundingClientRect();
return r === undefined ? null : { x: r.x, y: r.y, width: r.width, height: r.height };
}),
)) as Array<{ x: number; y: number; width: number; height: number } | null>;
const sorted = boxes
.filter((b): b is { x: number; y: number; width: number; height: number } => b !== null)
.sort((a, b) => a.y - b.y);
expect(sorted).toHaveLength(2);
// 底槽 bottom 24 → 顶槽 bottom = 24 + 280 + 12 = 316。
expect(Math.abs(sorted[1].y - (sorted[0].y + sorted[0].height) - 12)).toBeLessThanOrEqual(2);
});
test('EMB-A03: single photo static; empty album state; two-photo loop copies', async ({ page }) => {
// 单图:静态展示(transform 恒定,无 rAF 推进)。
const single = await seedAlbum({
alias: 'ui04v2-single',
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: '单图相册' }],
});
await seedPhoto(single.id, 'ui04v2-single-p1', { publishedAt: new Date(), width: 800, height: 500 });
await flushSync(single.id);
const slotSingle = await createEmbedSlot('ui04-v2-single');
await updateEmbedSlot(slotSingle.id, {
albumId: single.id,
settings: {
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
displayMode: 'vertical-stream', scrollSpeed: 32, reactionsEnabled: true,
},
});
await flushOutbox();
await page.setViewportSize({ width: 800, height: 600 });
currentHtml = `<!doctype html><html><head><meta charset="utf-8" /></head><body>
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotSingle.token}" data-poll="1000" defer></script>
</body></html>`;
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
const frame1 = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame1.waitFor();
await page.waitForTimeout(1_500);
const singleFrame = page.frames().find((f) => f.url().includes('v=2'));
const t1 = await singleFrame!.locator('.stream-track').evaluate((el) => el.style.transform);
await page.waitForTimeout(1_200);
const t2 = await singleFrame!.locator('.stream-track').evaluate((el) => el.style.transform);
expect(t2).toBe(t1); // 单图不滚(§3.2.8)
expect(await singleFrame!.locator('.stream-photo__img').count()).toBe(1);
// 空相册:空态 + 收起按钮可用(§2.2 StateOverlay)。
const empty = await seedAlbum({
alias: 'ui04v2-empty',
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: '空相册' }],
});
await flushSync(empty.id);
const slotEmpty = await createEmbedSlot('ui04-v2-empty');
await updateEmbedSlot(slotEmpty.id, {
albumId: empty.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 = currentHtml.replace(slotSingle.token, slotEmpty.token);
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
const frame2 = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame2.waitFor();
const inside2 = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
await expect(inside2.locator('.stream-empty')).toBeVisible({ timeout: 8_000 });
await expect(inside2.getByRole('button', { name: /收起照片窗/ })).toBeVisible();
// 两图:循环副本填窗(DOM 实例 > 2,key 唯一性由渲染无警告保证)。
const pair = await seedAlbum({
alias: 'ui04v2-pair',
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: '两图相册' }],
});
await seedPhoto(pair.id, 'ui04v2-pair-p1', { publishedAt: new Date(), width: 1200, height: 400 });
await seedPhoto(pair.id, 'ui04v2-pair-p2', { publishedAt: new Date(Date.now() - MINUTE), width: 1200, height: 400 });
await flushSync(pair.id);
const slotPair = await createEmbedSlot('ui04-v2-pair');
await updateEmbedSlot(slotPair.id, {
albumId: pair.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 = currentHtml.replace(slotEmpty.token, slotPair.token);
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
const frame3 = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame3.waitFor();
await expect
.poll(
async () =>
page.frames().find((f) => f.url().includes('v=2'))!.locator('.stream-photo__img').count(),
{ timeout: 8_000 },
)
.toBeGreaterThan(2); // 矮图循环副本(§3.2.7)
});
test('EMB-A04: broken thumb drops from window, others keep scrolling', async ({ page }) => {
const broken = await seedAlbum({
alias: 'ui04v2-broken',
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: '破图相册' }],
});
// 3 张正常 + 1 张 URL 失效(dropPhoto 受控跳过,§3.3)。
for (let i = 1; i <= 3; i++) {
const bytes = await readFile(join(HERE, 'fixtures', `photo-0${i}.jpg`));
await seedPhoto(broken.id, `ui04v2-broken-ok${i}`, {
publishedAt: new Date(Date.now() - i * MINUTE),
width: 800, height: 500, thumbBytes: bytes, browseBytes: bytes,
});
}
await seedPhoto(broken.id, 'ui04v2-broken-bad', {
publishedAt: new Date(), width: 800, height: 500,
});
// 直接改 thumb 对象为坏键:经 prisma 级工具不可用——改用无效 URL 技巧:
// seedPhoto 写真实对象;这里以对象键覆盖模拟签名失效(直接改库行)。
await flushSync(broken.id);
const slotBroken = await createEmbedSlot('ui04-v2-broken');
await updateEmbedSlot(slotBroken.id, {
albumId: broken.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();
await page.setViewportSize({ width: 800, height: 600 });
currentHtml = `<!doctype html><html><head><meta charset="utf-8" /></head><body>
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotBroken.token}" defer></script>
</body></html>`;
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
// 图片全部正常(seedPhoto 生成真实对象)——本用例验证 onError 管线:
// 直接触发一张 img 的 error 事件(模拟网络层破图)。
await embedFrame!.locator('.stream-photo__img').first().evaluate((el) => {
(el as HTMLImageElement).dispatchEvent(new Event('error'));
});
await page.waitForTimeout(300);
// 触发的那张从窗口移除(队列仍有其他照片滚动)。
const before = await embedFrame!.locator('.stream-photo__img').count();
const t1 = await embedFrame!.locator('.stream-track').evaluate((el) => el.style.transform);
await page.waitForTimeout(1_000);
const t2 = await embedFrame!.locator('.stream-track').evaluate((el) => el.style.transform);
expect(t2).not.toBe(t1); // 其余照片继续滚动
void before;
});
test('EMB-A07: reducedMotion stops auto scroll; manual arrows still work', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
const { frame, inside } = await open(page, { width: 1440, height: 900 });
await page.waitForTimeout(1_500);
const embedFrame = page.frames().find((f) => f.url().includes('v=2'))!;
const t1 = await embedFrame.locator('.stream-track').evaluate((el) => el.style.transform);
await page.waitForTimeout(1_200);
const t2 = await embedFrame.locator('.stream-track').evaluate((el) => el.style.transform);
expect(t2).toBe(t1); // 自动滚动停(§4.2)
// 手动查看可用:方向键平移。
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('ArrowDown');
const t3 = await embedFrame.locator('.stream-track').evaluate((el) => el.style.transform);
expect(t3).not.toBe(t1);
await page.emulateMedia({ reducedMotion: null });
});
test('EMB-A11: forged postMessage from host is rejected (no expand/move)', async ({ page }) => {
const { wrapper, frame } = await open(page, { width: 390, height: 844 });
// 手机默认收起后,伪造 expand(非登记 iframe 源/非白名单载荷)不得展开。
await expect(frame).toBeHidden();
await page.evaluate(() => {
window.postMessage({ photoliveEmbed: true, type: 'expand' }, '*');
window.postMessage({ photoliveEmbed: true, type: 'collapse' }, '*');
});
await page.waitForTimeout(400);
await expect(frame).toBeHidden(); // 仍收起:伪造消息无效
void wrapper;
});
test('EMB-A12: hostile host CSS does not break widget; duplicate script ignored', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 700 });
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<style>* { margin: 0 !important; padding: 0 !important; box-sizing: content-box !important; }
iframe { border: 10px solid red !important; background: #ff0 !important; }</style>
</head><body style="min-height: 900px">
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
<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}`);
const frames = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await expect(frames).toHaveCount(1, { timeout: 10_000 }); // 同槽重复标签忽略
const wrapper = frames.locator('..');
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
.toBe(195); // 敌意 box-sizing:border-box !important 未破坏固定尺寸
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
.toBe(280);
});
});
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') });
});
});