Files
photo-live/apps/viewer/e2e/embed.spec.ts
T
浅念 8f2c21d25a feat(viewer): embed carousel iframe with visibility pause
P4b Task 4:宿主 loader 以 iframe 载入的轮播页(/embed/:slotToken,裸布局)。

- src/embed/feed.ts:纯函数集——A22 旧响应裁决(dispatch seq + configVersion
  双闸:过期/回看丢弃、等值差分合并、升值全量重置)、照片队列差分(新照片
  追加队尾、缺席淘汰、thumb 签名刷新)、interval/高度夹取、轮询退避;
- src/embed/useEmbedFeed.ts:取数编排(If-None-Match → 304 零处理)、轮询
  (查询 interval 覆盖 → EMBED_POLL_INTERVAL_MS,连续 5 失败 ×5 退避,成功
  归零;404/410 静默 collapse 隐藏自身)、A23 不可见暂停(document 隐藏 +
  IntersectionObserver 滚出视口 + 本地收起;恢复立即校准)、轮播 intervalMs
  切换;__embedPollMs e2e 注入点;
- src/embed/postMessage.ts:上行 resize/collapse(referrer origin 收敛,缺失
  退 '*'——载荷无敏感数据,宿主侧 loader source+origin 双校验)、下行 expand
  (event.source === window.parent 白名单);
- EmbedPage.vue:交叉淡入淡出(仅 thumb 小图省流量)、点击 window.open 单图
  深链(noopener)、计数圆点、「照片即将上线」空态卡、×收起;lang 查询
  (auto→相册 defaultLocale)驱动嵌入词条子集;
- api client:fetchPublicEmbedSlot(304/ETag 分支,不走 apiRequest);
- e2e:宿主夹具页 public/__test__/embed-host.html(仅测试资产)+ embed.spec 7
  用例(轮换/新标签深链/收起记忆/窄屏 sm/轮询增量/换绑重置/空态/幽灵槽);
  helpers 增展示位 admin API 种子 + flushOutbox;
- vitest 18 用例(裁决矩阵/队列差分/夹取/退避/304/可见性状态机)。

配套修正:api CORS exposedHeaders ETag(跨源浏览器可读,304 语义成立);
contracts 新增浏览器安全子路径 ./constants(根出口 re-export internal-sync
的 node:crypto 会打进浏览器包);eslint ignore embed-loader 产物拷贝
(Task 3 起 viewer lint 红,先修)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 23:02:07 +08:00

227 lines
10 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 { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
createEmbedSlot,
flushOutbox,
flushSync,
makePublicToken,
seedAlbum,
seedPhoto,
updateEmbedSlot,
} from './helpers';
// P4b Task 4 嵌入轮播组件 Playwright(真实后端:cn 8080 + ov 8081 + viewer dev
// 5175 由 playwright.config webServer 拉起或复用)。宿主夹具页
// /__test__/embed-host.html(viewer publicDir 静态资产,仅测试用)引入真实
// loader.js(/embed/v1/loader.js)→ iframe 载 /embed/{slot} → 走 ov 公开端点。
// 展示位种子经 cn admin API 真实路径(seedAdmin + POST/PATCH),slot.updated
// 事件以 flushOutbox 排空(不随相册 bootstrap 走,见 helpers 注)。
// 断言锚点:loader 侧 wrapper(iframe 父 div,inline style)/ 把手(div[role=button]);
// iframe 内 viewer 侧 CSS 类名。CSP:夹具页不设(宿主真实 CSP 要求见
// packages/embed-loader/README-embed.md,此仅文档化)。
const MINUTE = 60_000;
interface SeededFeed {
slotToken: string;
slotId: string;
albumToken: string;
photoIds: string[];
}
/** 相册(zh 默认 + en 双语)+ 3 张错峰发布照片 + 展示位绑定 + ov 投影。 */
async function seedFeed(alias: string): Promise<SeededFeed> {
const album = await seedAlbum({
alias,
defaultLocale: 'zh-CN',
translations: [
{ locale: 'zh-CN', title: `${alias} 中文相册`, description: '嵌入轮播' },
{ locale: 'en', title: `${alias} EN Album`, description: 'embed feed' },
],
});
const base = Date.now() - 10 * MINUTE;
const photoIds = [
await seedPhoto(album.id, `${alias}-p1`, { publishedAt: new Date(base - 2 * MINUTE) }),
await seedPhoto(album.id, `${alias}-p2`, { publishedAt: new Date(base - MINUTE) }),
await seedPhoto(album.id, `${alias}-p3`, { publishedAt: new Date(base) }),
];
await flushSync(album.id);
const slot = await createEmbedSlot(`viewer-e2e-${alias}`);
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
return { slotToken: slot.token, slotId: slot.id, albumToken: album.publicToken, photoIds };
}
/** loader wrapper(iframe 父 div)inline height(px 字串;''=未置)。 */
function wrapperHeight(page: Page): Promise<string> {
return page.locator('iframe').evaluate((frame) => (frame.parentElement as HTMLElement).style.height);
}
test.describe.serial('embed carousel widget (P4b Task 4)', () => {
test.afterAll(async () => {
await cleanupSeededAlbums();
});
test('host page shows carousel, rotates photos and opens the viewer photo in a new tab', async ({ page }) => {
const feed = await seedFeed('main');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
// lang=auto → 相册 defaultLocale(zh-CN)标题。
await expect(frame.locator('.embed-title')).toHaveText('main 中文相册');
// 轮播照片就位(3 张,thumb 为真实签名 URL)。
await expect(frame.locator('.embed-photo img').first()).toBeVisible();
await expect(frame.locator('.embed-photo')).toHaveCount(3);
await expect(frame.locator('.embed-dot')).toHaveCount(3);
// intervalMs 切换:当前展示照片变化(首个为 publishedAt 最新 p3)。
const firstSrc = await frame.locator('.embed-photo.active img').getAttribute('src');
await expect
.poll(async () => frame.locator('.embed-photo.active img').getAttribute('src'), { timeout: 10_000 })
.not.toBe(firstSrc);
// 点击照片 → 新标签打开 viewer 单图深链(noopener;lang 跟随 resolvedLocale)。
const popupPromise = page.context().waitForEvent('page');
await frame.locator('.embed-photo.active').click();
const popup = await popupPromise;
await expect(popup).toHaveURL(new RegExp(`/a/${feed.albumToken}/p/`));
await expect(popup).toHaveURL(/lang=zh-CN/);
await popup.close();
});
test('collapse button hides the widget, persists after reload, and the handle expands it', async ({ page }) => {
const feed = await seedFeed('collapse');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册');
// iframe 内 × → postMessage collapse → loader wrapper 高度 0 + 把手出现 + 会话记忆。
await frame.locator('.embed-close').click();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect
.poll(() => page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), feed.slotToken))
.toBe('1');
// 刷新仍收起(会话内不反复弹出,需求 9)。
await page.reload();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
// 把手点击 → loader 展开回记忆高度,并向 iframe 发 expand(恢复轮询)。
await page.locator('div[role="button"]').click();
await expect
.poll(async () => (await wrapperHeight(page)) !== '0px')
.toBe(true);
await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册');
});
test('narrow viewport starts collapsed and downgrades to sm size', async ({ page }) => {
const feed = await seedFeed('narrow');
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}`);
// <480px:强制 collapsedDefault + sm(280px 宽,计划评审缺口:移动端可用)。
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
const width = await page
.locator('iframe')
.evaluate((frame) => (frame.parentElement as HTMLElement).style.width);
expect(width).toBe('280px');
// 把手展开可用(移动端不因默认收起而不可达)。
await page.locator('div[role="button"]').click();
await expect
.poll(async () => (await wrapperHeight(page)) !== '0px')
.toBe(true);
});
test('newly published photo appears within the poll window', async ({ page }) => {
const album = await seedAlbum({
alias: 'poll',
translations: [{ locale: 'zh-CN', title: '轮询增量相册' }],
});
await seedPhoto(album.id, 'poll-p1', { publishedAt: new Date(Date.now() - MINUTE) });
await flushSync(album.id);
const slot = await createEmbedSlot('viewer-e2e-poll');
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
// ?poll=2000 → 夹具页向 iframe 注入 __embedPollMs(同源;轮询 2s)。
await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-photo')).toHaveCount(1);
// 新照片发布(真实管线同步 ov,ETag 必变 → 200 增量)→ 追加队尾(A11 数据路径)。
await seedPhoto(album.id, 'poll-p2', { publishedAt: new Date() });
await flushSync(album.id);
await expect
.poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 })
.toBe(2);
});
test('rebinding to another album resets the feed (configVersion bump, A22)', async ({ page }) => {
const albumA = await seedAlbum({
alias: 'rebind-a',
translations: [{ locale: 'zh-CN', title: '换绑前相册' }],
});
await seedPhoto(albumA.id, 'rebind-a-p1', { publishedAt: new Date(Date.now() - 2 * MINUTE) });
await seedPhoto(albumA.id, 'rebind-a-p2', { publishedAt: new Date(Date.now() - MINUTE) });
const albumB = await seedAlbum({
alias: 'rebind-b',
translations: [{ locale: 'zh-CN', title: '换绑后相册' }],
});
await seedPhoto(albumB.id, 'rebind-b-p1', { publishedAt: new Date(Date.now()) });
await flushSync(albumA.id);
await flushSync(albumB.id);
const slot = await createEmbedSlot('viewer-e2e-rebind');
await updateEmbedSlot(slot.id, { albumId: albumA.id });
await flushOutbox();
await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('换绑前相册');
await expect(frame.locator('.embed-photo')).toHaveCount(2);
// 换绑:configVersion+1 → 轮询发现版本升 → 全量重置(新相册标题 + 新照片集,
// 旧相册照片不残留——A22「旧响应不串图」)。
await updateEmbedSlot(slot.id, { albumId: albumB.id });
await flushOutbox();
await expect
.poll(async () => frame.locator('.embed-title').textContent(), { timeout: 20_000 })
.toBe('换绑后相册');
await expect
.poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 })
.toBe(1);
});
test('album without photos shows the coming-soon card', async ({ page }) => {
const album = await seedAlbum({
alias: 'emptyslot',
translations: [{ locale: 'zh-CN', title: '空相册展示位' }],
});
await flushSync(album.id);
const slot = await createEmbedSlot('viewer-e2e-empty');
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
await page.goto(`/__test__/embed-host.html?slot=${slot.token}`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('空相册展示位');
await expect(frame.locator('.embed-empty')).toHaveText('照片即将上线');
});
test('ghost slot collapses silently with no error UI inside the iframe', async ({ page }) => {
await page.goto(`/__test__/embed-host.html?slot=${makePublicToken()}`);
// 404(不存在/停用/未绑定同码防探测)→ postMessage collapse:wrapper 高度 0 +
// 把手出现;iframe 内无错误 UI(root 隐藏,不弹错误)。
await expect.poll(() => wrapperHeight(page), { timeout: 15_000 }).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect(page.frameLocator('iframe').locator('.embed-root')).toHaveCount(0);
});
});