Files
photo-live/apps/viewer/e2e/embed.spec.ts
T
浅念 7385785504 feat(i18n): T15 full-stack language audit with coverage assertions
- viewer/admin 错误码→文案映射按契约 ErrorCodes 枚举全集登记(机器守护
  spec 遍历枚举断言无缺漏 + 映射键在 zh/en 词条均存在 + locale 键集
  递归相等);mobile 镜像补齐 DOWNLOAD_DISABLED(P4a 增补漏登)
- 漏译修复:viewer.photoRemoved en 侧缺失(审计测试捕获);
  DOWNLOAD_DISABLED 观众侧专属文案 + useDownload 专属提示接线;
  admin apiMsg 增 RATE_LIMITED/ALBUM_NOT_PUBLIC/DOWNLOAD_DISABLED/
  SYNC_AUTH_FAILED 四码词条
- 运行时抽查:admin 主旅程 en 版 e2e(登录→相册管理全英文 + 偏好
  持久);embed iframe lang=en 渲染与深链跟随 e2e(zh 侧既有用例覆盖)
- contracts 新增 ./errors 浏览器安全子路径(errors.ts 纯 TS);
  admin 增 @photo/contracts devDep(仅测试消费)

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

247 lines
11 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();
});
// T15(A12)embed lang 参数两语言渲染断言之 en 侧(zh 侧由 lang=auto →
// defaultLocale 各用例覆盖):loader data-lang=en → iframe 内英文标题,
// 深链 lang=en 跟随。
test('lang=en renders English copy in the iframe and passes en to the photo deep link', async ({ page }) => {
const feed = await seedFeed('langen');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&lang=en`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('langen EN Album');
await expect(frame.locator('.embed-photo').first()).toBeVisible();
// 点击 → 新标签深链携带 lang=en(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=en/);
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);
});
});