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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user