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 { 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) => ``) .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 ` cross-origin host fixture ${hostile ? `` : ''}

Host page (cross-origin, port ${HOST_PORT})

${tags} `; } 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 { currentHtml = html; await page.goto(HOST_ORIGIN); } /** wrapper(iframe 父 div)inline 样式属性值。 */ function wrapperStyle(page: Page, prop: string): Promise { return page.locator('iframe').evaluate( (frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record)[p], prop, ); } test.describe.serial('embed loader on a cross-origin host (UI-01 Task 6)', () => { test.beforeAll(async () => { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(HOST_PORT, '127.0.0.1', () => resolve()); }); }); test.afterAll(async () => { await new Promise((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'); }); });