feat(embed,ui04): E07 acceptance — matrix completion, hostile-CSS hardening, measurements and record closure

- E07 e2e (7 cases): 4-viewport fixed size, narrow shrink + dual-slot 12px stack, single/static + empty state + two-photo loop copies, broken-thumb drop, reducedMotion stop with manual arrows, forged postMessage rejected, hostile CSS (!important box-sizing/border) with inline-important countermeasures + duplicate script dedup
- loader v2: geometry-critical properties upgraded to inline !important (beats host stylesheet !important)
- pre-existing fixes: likes.spec view-count assertion vs concurrent session's AlbumHeader unicode icons (toContainText); embed-v2 host port moved to 3998 (3999 collides with embed-crossorigin under parallel full runs); prettier formatting of two concurrent-session files (formatting-only)
- evidence: matrix EMB-A01..A16 closed in acceptance record (mechanism-level items honestly marked), measurements (loader v2 9885B/gzip 3959B, first-load 1 GET + 1 WS, DOM<=16), refreshed screenshots/recordings, master plan UI-04 note
- regression: root lint/typecheck/test 12 packages green; embed suites 43/43; admin embed-slots 5/5; journey pre-existing env failure reproduced at clean HEAD (registered)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 18:56:38 +08:00
parent 80a7658f81
commit 8c4906c2f2
15 changed files with 385 additions and 41 deletions
+292 -1
View File
@@ -18,7 +18,7 @@ import {
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer;E02 = 静态高保真测量。
// 照片种子为真实摄影样本(e2e/fixtures,picsum 固定 seed 下载——验收 §11.1
// 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。
const HOST_PORT = 3999;
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));
@@ -522,6 +522,297 @@ test.describe.serial('E05 recording: scroll + real like hearts', () => {
});
});
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 });