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

207 lines
8.8 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 { createServer, type Server } from 'node:http';
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
createEmbedSlot,
flushOutbox,
flushSync,
makePublicToken,
seedAlbum,
seedPhoto,
updateEmbedSlot,
} from './helpers';
// UI-01 Task 6 跨源宿主 Playwright:宿主页与 viewer/iframe 真正不同源
// (宿主 127.0.0.1:3999 ← 自建 node:http 静态页;loader/iframe/ov 均 5175/8081)。
// 覆盖:跨源渲染、postMessage 双向白名单(非登记 iframe 来源的伪造消息被忽略)、
// ghost 槽 destroy 无残留、同侧多槽纵排(8px 间距堆叠)、敌意宿主全局 CSS 下
// 仍可用(loader 仅 inline style 零污染的逆向断言)、窄视口默认收起。
// 加速取数不依赖 __embedPollMs(跨源不可写):本文件用例均为首屏行为,无需轮询。
const HOST_PORT = 3999;
const HOST_ORIGIN = `http://127.0.0.1:${HOST_PORT}`;
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
const MINUTE = 60_000;
interface SeededFeed {
slotToken: string;
slotId: string;
}
async function seedFeed(alias: string): Promise<SeededFeed> {
const album = await seedAlbum({
alias,
defaultLocale: 'zh-CN',
translations: [{ locale: 'zh-CN', title: `${alias} 中文相册` }],
});
await seedPhoto(album.id, `${alias}-p1`, { publishedAt: new Date(Date.now() - 2 * MINUTE) });
await seedPhoto(album.id, `${alias}-p2`, { publishedAt: new Date(Date.now() - MINUTE) });
await flushSync(album.id);
const slot = await createEmbedSlot(`viewer-e2e-xorigin-${alias}`);
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
return { slotToken: slot.token, slotId: slot.id };
}
/** 跨源宿主夹具页:inline HTML 由测试写入 currentHtml(每个用例自定义变体)。 */
function hostHtml(options: { slots: string[]; hostileCss?: boolean }): string {
const tags = options.slots
.map((slot) => `<script src="${VIEWER_ORIGIN}/embed/v1/loader.js" data-slot="${slot}" defer></script>`)
.join('\n ');
const hostile = options.hostileCss
? `* { margin: 0 !important; padding: 0 !important; box-sizing: content-box !important; }
iframe { border: 10px solid red !important; background: #ff0 !important; }`
: '';
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cross-origin host fixture</title>
${hostile ? `<style>\n ${hostile}\n </style>` : ''}
</head>
<body style="min-height: 2000px">
<h1>Host page (cross-origin, port ${HOST_PORT})</h1>
${tags}
</body>
</html>`;
}
let currentHtml = '';
const server: Server = createServer((_req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(currentHtml);
});
async function openHost(page: Page, html: string): Promise<void> {
currentHtml = html;
await page.goto(HOST_ORIGIN);
}
/** 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,
);
}
test.describe.serial('embed loader on a cross-origin host (UI-01 Task 6)', () => {
test.beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(HOST_PORT, '127.0.0.1', () => resolve());
});
});
test.afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await cleanupSeededAlbums();
});
test('renders the widget from a cross-origin host page', async ({ page }) => {
const feed = await seedFeed('xmain');
await openHost(page, hostHtml({ slots: [feed.slotToken] }));
// iframe 以 title 定位(跨源 frameLocator 仍可穿透断言 DOM)。
const frame = page.frameLocator('iframe[title="PhotoLive photos"]');
await expect(frame.locator('.embed-title')).toHaveText('xmain 中文相册');
await expect(frame.locator('.embed-photo.active img')).toBeVisible();
// loader 几何:fixed 贴右下(默认 offset 16/24),不依赖宿主样式。
await expect.poll(() => wrapperStyle(page, 'position')).toBe('fixed');
await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px');
await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('24px');
});
test('postMessages not originating from the registered iframe are ignored', async ({ page }) => {
const feed = await seedFeed('xmsg');
await openHost(page, hostHtml({ slots: [feed.slotToken] }));
const frame = page.frameLocator('iframe[title="PhotoLive photos"]');
await expect(frame.locator('.embed-title')).toHaveText('xmsg 中文相册');
// 宿主页脚本伪造 collapse/destroy(source=宿主 window ≠ 登记 iframe):白名单
// 拒收——不收起、不销毁、不出现把手。
await page.evaluate(() => {
window.postMessage({ photoliveEmbed: true, type: 'collapse' }, '*');
window.postMessage({ photoliveEmbed: true, type: 'destroy' }, '*');
window.postMessage({ photoliveEmbed: true, type: 'config', configVersion: 99, settings: {} }, '*');
});
await page.waitForTimeout(800);
expect(await page.locator('iframe').count()).toBe(1);
expect(await page.locator('div[role="button"]').count()).toBe(0);
expect(await wrapperStyle(page, 'height')).not.toBe('0px');
await expect(frame.locator('.embed-title')).toHaveText('xmsg 中文相册');
});
test('ghost slot destroys the wrapper, handle and session key cross-origin', async ({ page }) => {
const ghost = makePublicToken();
await openHost(page, hostHtml({ slots: [ghost] }));
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();
});
test('multiple slots on the same side stack vertically with an 8px gap', async ({ page }) => {
const feedA = await seedFeed('xstacka');
const feedB = await seedFeed('xstackb');
await openHost(page, hostHtml({ slots: [feedA.slotToken, feedB.slotToken] }));
// 两个 iframe 各自渲染(src 定位到各自槽)。
await expect(
page.frameLocator(`iframe[src*="${feedA.slotToken}"]`).locator('.embed-title'),
).toHaveText('xstacka 中文相册');
await expect(
page.frameLocator(`iframe[src*="${feedB.slotToken}"]`).locator('.embed-title'),
).toHaveText('xstackb 中文相册');
// 纵排:首个槽 bottom=offsetBottom(24);后续槽 bottom = 24 + 首槽高 + 8。
// 轮询至两个 wrapper 高度均已由 resize 消息落定且堆叠算式成立再读值复核。
const readBoxes = () =>
page.evaluate(() =>
Array.from(document.querySelectorAll('iframe')).map((f) => {
const s = (f.parentElement as HTMLElement).style;
return { bottom: parseInt(s.bottom, 10), height: parseInt(s.height, 10) };
}),
);
await expect
.poll(async () => {
const boxes = await readBoxes();
return boxes.length === 2 && boxes[1].bottom === 24 + boxes[0].height + 8;
}, { timeout: 15_000 })
.toBe(true);
const boxes = await readBoxes();
expect(boxes[0].bottom).toBe(24);
expect(boxes[0].height).toBeGreaterThan(0);
expect(boxes[1].bottom).toBe(24 + boxes[0].height + 8);
});
test('widget survives hostile host-global CSS (inline styles only)', async ({ page }) => {
const feed = await seedFeed('xcss');
await openHost(page, hostHtml({ slots: [feed.slotToken], hostileCss: true }));
// 敌意全局 reset(!*important* 覆盖、iframe 边框/底色强改)下:组件仍渲染、
// 仍 fixed 定位在默认几何(loader 全 inline style,不依赖宿主 CSS 约定)。
const frame = page.frameLocator('iframe[title="PhotoLive photos"]');
await expect(frame.locator('.embed-title')).toHaveText('xcss 中文相册');
await expect(frame.locator('.embed-photo.active img')).toBeVisible();
await expect.poll(() => wrapperStyle(page, 'position')).toBe('fixed');
await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px');
await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('24px');
});
test('narrow viewport starts collapsed with the handle on a cross-origin host', async ({ page }) => {
const feed = await seedFeed('xnarrow');
await page.setViewportSize({ width: 375, height: 667 });
await openHost(page, hostHtml({ slots: [feed.slotToken] }));
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect.poll(() => wrapperStyle(page, 'height')).toBe('0px');
await expect.poll(() => wrapperStyle(page, 'width')).toBe('280px');
});
});