2026-09-22 12:50:28 +08:00
|
|
|
|
import { createServer, type Server } from 'node:http';
|
2026-09-22 13:19:21 +08:00
|
|
|
|
import { readFile, rename } from 'node:fs/promises';
|
2026-09-22 13:01:34 +08:00
|
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
2026-09-22 12:50:28 +08:00
|
|
|
|
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 逐任务扩展:
|
2026-09-22 13:01:34 +08:00
|
|
|
|
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer;E02 = 静态高保真测量。
|
|
|
|
|
|
// 照片种子为真实摄影样本(e2e/fixtures,picsum 固定 seed 下载——验收 §11.1
|
|
|
|
|
|
// 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。
|
2026-09-22 18:56:38 +08:00
|
|
|
|
const HOST_PORT = 3998; // 独立于 embed-crossorigin(3999):并行全量跑时互不串夹具内容
|
2026-09-22 12:50:28 +08:00
|
|
|
|
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
|
|
|
|
|
|
const MINUTE = 60_000;
|
2026-09-22 13:01:34 +08:00
|
|
|
|
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],
|
|
|
|
|
|
];
|
2026-09-22 12:50:28 +08:00
|
|
|
|
|
|
|
|
|
|
let slotToken = '';
|
2026-09-22 17:38:02 +08:00
|
|
|
|
let albumToken = '';
|
2026-09-22 12:50:28 +08:00
|
|
|
|
|
|
|
|
|
|
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` },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
2026-09-22 17:38:02 +08:00
|
|
|
|
albumToken = album.publicToken;
|
2026-09-22 13:01:34 +08:00
|
|
|
|
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,
|
2026-09-22 12:50:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 17:38:02 +08:00
|
|
|
|
// 文件级 serial:config fullyParallel 会把同文件用例分散到多 worker,各自
|
|
|
|
|
|
// 执行 beforeAll(种子/夹具服务器/admin 幂等种子并发冲突)。整文件单 worker
|
|
|
|
|
|
// 串行,beforeAll 恰好一次。
|
|
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
|
|
|
2026-09-22 12:50:28 +08:00
|
|
|
|
test.beforeAll(async () => {
|
|
|
|
|
|
slotToken = await seedFeed('ui04v2');
|
2026-09-22 13:01:34 +08:00
|
|
|
|
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());
|
|
|
|
|
|
});
|
2026-09-22 12:50:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-22 13:01:34 +08:00
|
|
|
|
|
2026-09-22 13:19:21 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 13:28:13 +08:00
|
|
|
|
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 16:05:53 +08:00
|
|
|
|
// 宿主内弹框(用户决策 2026-09-23):不新开标签,宿主页覆盖层 + 详情 iframe。
|
|
|
|
|
|
let popupOpened = false;
|
|
|
|
|
|
page.once('popup', () => {
|
|
|
|
|
|
popupOpened = true;
|
|
|
|
|
|
});
|
2026-09-22 13:28:13 +08:00
|
|
|
|
await target.click();
|
2026-09-23 16:05:53 +08:00
|
|
|
|
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` });
|
2026-09-23 16:20:10 +08:00
|
|
|
|
await page
|
|
|
|
|
|
.locator('button[aria-label*="Close photo detail"], button[aria-label*="关闭照片详情"]')
|
|
|
|
|
|
.click();
|
2026-09-23 16:05:53 +08:00
|
|
|
|
await expect(modalFrame).toHaveCount(0);
|
2026-09-22 13:28:13 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-23 16:30:14 +08:00
|
|
|
|
// 聚焦流区域(程序化聚焦,不点击——照片点击现会打开宿主详情弹框)。
|
2026-09-22 13:28:13 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 17:38:02 +08:00
|
|
|
|
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 });
|
2026-09-23 16:36:51 +08:00
|
|
|
|
// 归属正确(常驻氛围心共存下的放宽断言):至少一颗心落在被赞照片交集
|
|
|
|
|
|
// 下侧区间 [origin-130, origin+5](事件心从照片底部涌起;氛围心带
|
|
|
|
|
|
// 140-244 恰好也常命中——两者共享层,无法逐颗区分,some() 语义足够)。
|
|
|
|
|
|
const hearts = await inside.locator('.heart').all();
|
2026-09-22 17:38:02 +08:00
|
|
|
|
const photoRect = await embedFrame!
|
|
|
|
|
|
.locator(`a.stream-photo[data-photo-id="${photoId}"]`)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.boundingBox();
|
|
|
|
|
|
expect(photoRect).not.toBeNull();
|
|
|
|
|
|
const frameOriginY = (await frame.boundingBox())!.y ?? 0;
|
|
|
|
|
|
const originFrame = photoRect!.y + photoRect!.height - 10 - frameOriginY;
|
2026-09-23 16:36:51 +08:00
|
|
|
|
const tops: number[] = [];
|
|
|
|
|
|
for (const h of hearts) {
|
|
|
|
|
|
const box = await h.boundingBox();
|
|
|
|
|
|
if (box !== null) tops.push(box.y - frameOriginY);
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(tops.some((t) => t <= originFrame + 5 && t >= originFrame - 130)).toBe(true);
|
2026-09-22 17:38:02 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 18:56:38 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 13:01:34 +08:00
|
|
|
|
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') });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|