66d1eb3e9f
- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green - api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings) - embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen - viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en - admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection) - tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer) Co-Authored-By: Claude Code <noreply@anthropic.com>
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
import { createServer, type Server } from 'node:http';
|
||
import { expect, test, type Page } from '@playwright/test';
|
||
import {
|
||
cleanupSeededAlbums,
|
||
createEmbedSlot,
|
||
flushOutbox,
|
||
flushSync,
|
||
seedAlbum,
|
||
seedPhoto,
|
||
updateEmbedSlot,
|
||
} from './helpers';
|
||
|
||
// UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ←
|
||
// viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展:
|
||
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer(本文件首批用例)。
|
||
const HOST_PORT = 3999;
|
||
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
|
||
const MINUTE = 60_000;
|
||
|
||
let slotToken = '';
|
||
|
||
async function seedFeed(alias: string): Promise<string> {
|
||
const album = await seedAlbum({
|
||
alias,
|
||
defaultLocale: 'zh-CN',
|
||
translations: [
|
||
{ locale: 'zh-CN', title: `${alias} 中文相册` },
|
||
{ locale: 'en', title: `${alias} Album` },
|
||
],
|
||
});
|
||
for (let i = 1; i <= 6; i++) {
|
||
await seedPhoto(album.id, `${alias}-p${i}`, {
|
||
publishedAt: new Date(Date.now() - (7 - i) * MINUTE),
|
||
});
|
||
}
|
||
await flushSync(album.id);
|
||
const slot = await createEmbedSlot(`ui04-v2-${alias}`);
|
||
// v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡)
|
||
await updateEmbedSlot(slot.id, {
|
||
albumId: album.id,
|
||
settings: {
|
||
position: 'right',
|
||
size: 'md',
|
||
intervalMs: 5000,
|
||
lang: 'auto',
|
||
collapsedDefault: false,
|
||
imageFit: 'contain',
|
||
offsetX: 16,
|
||
offsetBottom: 24,
|
||
zIndex: 1000,
|
||
displayMode: 'vertical-stream',
|
||
scrollSpeed: 16,
|
||
reactionsEnabled: true,
|
||
},
|
||
});
|
||
await flushOutbox();
|
||
return slot.token;
|
||
}
|
||
|
||
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, viewport: { width: number; height: number }): Promise<void> {
|
||
await page.setViewportSize(viewport);
|
||
currentHtml = `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>UI-04 v2 host</title></head>
|
||
<body style="min-height: 1200px; font-family: system-ui">
|
||
<h1>Host page (v2 stream)</h1>
|
||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
|
||
</body>
|
||
</html>`;
|
||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||
}
|
||
|
||
test.beforeAll(async () => {
|
||
slotToken = await seedFeed('ui04v2');
|
||
await new Promise<void>((resolve) => server.listen(HOST_PORT, '127.0.0.1', resolve));
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await cleanupSeededAlbums();
|
||
server.close();
|
||
});
|
||
|
||
test.describe.serial('E01 exit gate: fixed window, no footer', () => {
|
||
for (const [name, viewport] of [
|
||
['desktop 1440x900', { width: 1440, height: 900 }],
|
||
['mobile 390x844', { width: 390, height: 844 }],
|
||
] as const) {
|
||
test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => {
|
||
const mobile = viewport.width < 480;
|
||
await openHost(page, viewport);
|
||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||
const wrapper = frame.locator('..');
|
||
// 手机默认收起(沿用 v1 策略):点击把手展开后测量。
|
||
if (mobile) {
|
||
await page.locator('[role="button"]').first().click();
|
||
}
|
||
await expect(frame).toBeVisible();
|
||
await expect
|
||
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
|
||
.toBe(195);
|
||
await expect
|
||
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
|
||
.toBe(280);
|
||
// iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。
|
||
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||
await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible();
|
||
// v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部
|
||
// scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。
|
||
await expect(inside.locator('.embed-head')).toHaveCount(0);
|
||
await expect(inside.locator('.embed-footer')).toHaveCount(0);
|
||
await expect(inside.getByText('查看相册')).toHaveCount(0);
|
||
await expect(inside.getByText('照片轮播')).toHaveCount(0);
|
||
await expect(inside.locator('.stream-viewport')).toHaveCount(1);
|
||
});
|
||
}
|
||
});
|