Files
photo-live/apps/viewer/e2e/embed.spec.ts
T
2026-09-21 18:55:15 +08:00

383 lines
18 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';
// UI-01 Task 6 嵌入轮播组件 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 类名(.embed-carousel/.embed-stage/.embed-indicator 等,
// 见 src/pages/embed/*)。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 样式属性值(''=未置)。 */
function wrapperStyle(page: Page, prop: string): Promise<string> {
return page.locator('iframe').evaluate(
(frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record<string, string>)[p],
prop,
);
}
async function activeSrc(page: Page): Promise<string | null> {
return page.frameLocator('iframe').locator('.embed-photo.active img').getAttribute('src');
}
test.describe.serial('embed carousel widget (UI-01 Task 6)', () => {
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 中文相册');
await expect(frame.locator('.embed-photo.active img')).toBeVisible();
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
// intervalMs 切换:当前展示照片变化(首个为 publishedAt 最新 p3)。
const firstSrc = await activeSrc(page);
await expect.poll(() => activeSrc(page), { 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.active img')).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(() => wrapperStyle(page, 'height')).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(() => wrapperStyle(page, 'height')).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 wrapperStyle(page, 'height')) !== '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(() => wrapperStyle(page, 'height')).toBe('0px');
await expect.poll(() => wrapperStyle(page, 'width')).toBe('280px');
// 把手展开可用(移动端不因默认收起而不可达)。
await page.locator('div[role="button"]').click();
await expect
.poll(async () => (await wrapperStyle(page, 'height')) !== '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-indicator')).toHaveText('01 / 01');
// 新照片发布(真实管线同步 ov,ETag 必变 → 200 增量)→ 追加队尾(A11 数据路径);
// 页码分母即时反映队列长度(≠ 相册照片总数语义由组件单测覆盖)。
await seedPhoto(album.id, 'poll-p2', { publishedAt: new Date() });
await flushSync(album.id);
await expect
.poll(async () => (await frame.locator('.embed-indicator').textContent()) ?? '', { timeout: 20_000 })
.toMatch(/02$/);
});
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-indicator')).toHaveText('01 / 02');
// 换绑:configVersion+1 → 轮询发现版本升 → 全量重置(新相册标题 + 新照片集,
// 旧相册照片不残留——A22「旧响应不串图」)。
await updateEmbedSlot(slot.id, { albumId: albumB.id });
await flushOutbox();
await expect
.poll(async () => (await frame.locator('.embed-title').textContent()) ?? '', { timeout: 20_000 })
.toBe('换绑后相册');
await expect
.poll(async () => (await frame.locator('.embed-indicator').textContent()) ?? '', { timeout: 20_000 })
.toBe('01 / 01');
});
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-empty')).toHaveText('照片即将上线');
await expect(frame.locator('.embed-heading')).toHaveText('活动照片');
});
test('ghost slot is destroyed with no residue and no error UI (404 anti-probe)', async ({ page }) => {
const ghost = makePublicToken();
await page.goto(`/__test__/embed-host.html?slot=${ghost}`);
// 404(不存在/停用/未绑定同码防探测)→ postMessage destroy:宿主移除 wrapper
// 与把手(不留无效按钮)、清会话键;iframe 内无错误 UI。
await expect
.poll(() => page.locator('iframe').count(), { timeout: 15_000 })
.toBe(0);
await expect(page.locator('div[role="button"]')).toHaveCount(0);
expect(await page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), ghost)).toBeNull();
});
// ── 暂停条件全集(§5.2:自动暂停离开即恢复;手动暂停不自动恢复)──
test('footer pause button stops rotation; play resumes it', async ({ page }) => {
const feed = await seedFeed('pause');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
const srcBefore = await activeSrc(page);
await frame.getByRole('button', { name: '暂停轮播' }).click();
await page.waitForTimeout(4600);
await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? '');
await frame.getByRole('button', { name: '播放轮播' }).click();
// 点击把键盘焦点留在按钮上——焦点暂停(§5.2)仍在生效属预期;点击宿主页
// 移出焦点后自动恢复轮换。
await page.mouse.click(5, 5);
await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore);
});
test('pointer hover pauses rotation and leaving resumes it', async ({ page }) => {
const feed = await seedFeed('hover');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
const srcBefore = await activeSrc(page);
await frame.locator('.embed-carousel').hover();
await page.waitForTimeout(4600);
await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? '');
await page.mouse.move(0, 0);
await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore);
});
test('keyboard focus inside the component pauses rotation', async ({ page }) => {
const feed = await seedFeed('focus');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
const srcBefore = await activeSrc(page);
await frame.locator('.embed-photo-open').focus();
await page.waitForTimeout(4600);
await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? '');
await frame.locator('.embed-photo-open').blur();
await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore);
});
test('hidden page pauses rotation and returning resumes it', async ({ page }) => {
const feed = await seedFeed('hidden');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
const srcBefore = await activeSrc(page);
// jsdom 式覆写在真实 Chromium 同样可用:defineProperty 覆写 document.hidden
// 后派发 visibilitychange(iframe 内 useEmbedFeed + EmbedCarousel 双订阅)。
const child = page.frames().find((f) => f.url().includes('/embed/'));
expect(child).toBeDefined();
await child!.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(4600);
await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? '');
await child!.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
});
await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore);
});
// ── reduced motion(§5.2:默认暂停 + 手动切换;无过渡)──
test('prefers-reduced-motion defaults to paused with manual prev/next', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
const feed = await seedFeed('motion');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
// 默认暂停:不自动切换(按钮态为「播放」);手动上一张/下一张可用。
await expect(frame.getByRole('button', { name: '播放轮播' })).toBeVisible();
await page.waitForTimeout(4600);
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
await frame.getByRole('button', { name: '下一张' }).click();
await expect(frame.locator('.embed-indicator')).toHaveText('02 / 03');
await frame.getByRole('button', { name: '上一张' }).click();
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
});
// ── 后台设置动态生效(§5.4:configVersion 升值 → config 消息 → loader 二次
// 校验后应用非显式字段;iframe 内 imageFit 经响应 settings 生效)──
test('admin settings changes apply live via the config message', async ({ page }) => {
const feed = await seedFeed('cfg');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&poll=2000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03');
await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px');
await updateEmbedSlot(feed.slotId, {
settings: {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'cover',
offsetX: 60,
offsetBottom: 90,
zIndex: 2000,
},
});
await flushOutbox();
// loader 侧:wrapper 偏移/层级随 config 消息更新(无 data-* 显式覆盖时)。
await expect.poll(() => wrapperStyle(page, 'right'), { timeout: 20_000 }).toBe('60px');
await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('90px');
await expect.poll(() => wrapperStyle(page, 'zIndex')).toBe('2000');
// iframe 侧:imageFit=cover 应用到当前展示图(inline object-fit)。
await expect
.poll(() => frame.locator('.embed-photo.active img').evaluate((el) => el.style.objectFit))
.toBe('cover');
});
});