Files
photo-live/apps/viewer/e2e/helpers.ts
T
浅念 8f2c21d25a feat(viewer): embed carousel iframe with visibility pause
P4b Task 4:宿主 loader 以 iframe 载入的轮播页(/embed/:slotToken,裸布局)。

- src/embed/feed.ts:纯函数集——A22 旧响应裁决(dispatch seq + configVersion
  双闸:过期/回看丢弃、等值差分合并、升值全量重置)、照片队列差分(新照片
  追加队尾、缺席淘汰、thumb 签名刷新)、interval/高度夹取、轮询退避;
- src/embed/useEmbedFeed.ts:取数编排(If-None-Match → 304 零处理)、轮询
  (查询 interval 覆盖 → EMBED_POLL_INTERVAL_MS,连续 5 失败 ×5 退避,成功
  归零;404/410 静默 collapse 隐藏自身)、A23 不可见暂停(document 隐藏 +
  IntersectionObserver 滚出视口 + 本地收起;恢复立即校准)、轮播 intervalMs
  切换;__embedPollMs e2e 注入点;
- src/embed/postMessage.ts:上行 resize/collapse(referrer origin 收敛,缺失
  退 '*'——载荷无敏感数据,宿主侧 loader source+origin 双校验)、下行 expand
  (event.source === window.parent 白名单);
- EmbedPage.vue:交叉淡入淡出(仅 thumb 小图省流量)、点击 window.open 单图
  深链(noopener)、计数圆点、「照片即将上线」空态卡、×收起;lang 查询
  (auto→相册 defaultLocale)驱动嵌入词条子集;
- api client:fetchPublicEmbedSlot(304/ETag 分支,不走 apiRequest);
- e2e:宿主夹具页 public/__test__/embed-host.html(仅测试资产)+ embed.spec 7
  用例(轮换/新标签深链/收起记忆/窄屏 sm/轮询增量/换绑重置/空态/幽灵槽);
  helpers 增展示位 admin API 种子 + flushOutbox;
- vitest 18 用例(裁决矩阵/队列差分/夹取/退避/304/可见性状态机)。

配套修正:api CORS exposedHeaders ETag(跨源浏览器可读,304 语义成立);
contracts 新增浏览器安全子路径 ./constants(根出口 re-export internal-sync
的 node:crypto 会打进浏览器包);eslint ignore embed-loader 产物拷贝
(Task 3 起 viewer lint 红,先修)。

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

379 lines
16 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.
// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6
// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
// P2 Task 6 起 viewer 读 ov 副本(VITE_API_BASE 直连 8081)——直种只落 cn 库,
// 故 seed* 后必须 flushSync(albumId):经 cn bootstrap(当前态快照写回 outbox)
// + deliverPendingOnce(真实发送管线排空)把种子投影到 ov 副本,无旁路。
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
import {
SYNC_SIGNATURE_HEADER,
SYNC_TIMESTAMP_HEADER,
signSyncBody,
} from '@photo/contracts';
import { deliverPendingOnce } from '../../../services/sync/src/sender';
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
config({ path: join(repoRoot, '.env') });
const prisma = new PrismaClient();
const storage: MinioStorageAdapter = createStorageFromEnv();
const KEY_ROOT = 'photo-live/dev';
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
export function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
let token = '';
const bytes = randomBytes(24);
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
export interface SeedAlbumOptions {
alias: string;
defaultLocale?: string;
translations: Array<{
locale: string;
title: string;
description?: string;
venue?: string;
address?: string;
}>;
status?: string;
publicEnabled?: boolean;
/** 下载两级开关(P4a Task 4):缺省库默认 true/false(同 D06 工作默认)。 */
downloadEnabled?: boolean;
originalDownloadEnabled?: boolean;
timezone?: string;
startAt?: Date;
endAt?: Date | null;
}
const seededAlbumIds: string[] = [];
/** 经 Prisma 直种相册 + 翻译(行形状同 api 公开 e2e fixture)。 */
export async function seedAlbum(options: SeedAlbumOptions): Promise<{ id: string; publicToken: string }> {
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId: `u-viewer-e2e-${options.alias}`,
startAt: options.startAt ?? new Date('2026-10-01T02:00:00.000Z'),
endAt: options.endAt === undefined ? new Date('2026-10-01T12:00:00.000Z') : options.endAt,
timezone: options.timezone ?? 'Asia/Shanghai',
status: options.status ?? 'active',
defaultLocale: options.defaultLocale ?? 'zh-CN',
publicEnabled: options.publicEnabled ?? true,
downloadEnabled: options.downloadEnabled ?? true,
originalDownloadEnabled: options.originalDownloadEnabled ?? false,
},
select: { id: true, publicToken: true },
});
seededAlbumIds.push(album.id);
await prisma.album_translations.createMany({
data: options.translations.map((t) => ({
albumId: album.id,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
return album;
}
/** 直种分类 + 翻译(名称按 locale 解析),返回 id。 */
export async function seedCategory(
albumId: string,
sortOrder: number,
translations: Array<{ locale: string; name: string }>,
): Promise<string> {
const category = await prisma.categories.create({
data: { albumId, sortOrder },
select: { id: true },
});
await prisma.category_translations.createMany({
data: translations.map((t) => ({ categoryId: category.id, locale: t.locale, name: t.name })),
});
return category.id;
}
/** 640x200 纯色 JPEG(sharp 生成,仅作真实可取回对象——公开端点只做签名,不校验内容)。 */
export const BANNER_JPEG = Buffer.from(
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCADIAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAQF/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AgDSRgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==',
'base64',
);
/** 直种 banner 资产行 + MinIO 真实对象(kind=banner, state=effective, locale 全局)。 */
export async function seedBannerAsset(albumId: string): Promise<void> {
const objectKey = `${KEY_ROOT}/albums/${albumId}/assets/banner.jpg`;
await storage.put(objectKey, BANNER_JPEG, 'image/jpeg');
await prisma.album_assets.create({
data: { albumId, kind: 'banner', locale: null, objectKey, state: 'effective' },
});
}
/** 测试内生成真实 JPEG(sharp,同 services/api test 约定;尺寸小控制体积/耗时)。 */
async function makeJpeg(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
})
.jpeg({ quality: 70 })
.toBuffer();
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 与 worker publish.service variantsObjectKey 同布局的变体键。 */
function variantKey(albumId: string, photoId: string, variant: 'thumb' | 'browse'): string {
return `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
}
export interface SeedPhotoOptions {
categoryId?: string | null;
/** browse 宽高(驱动列表占位与瀑布流比例;thumb 减半生成)。 */
width?: number;
height?: number;
publishedAt: Date;
}
/** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+
* thumb/browse 变体行 + MinIO 真实对象。publishedAt 必传(列表排序确定性)。 */
export async function seedPhoto(
albumId: string,
alias: string,
options: SeedPhotoOptions,
): Promise<string> {
const photoId = cuidLike(alias);
const width = options.width ?? 480;
const height = options.height ?? 300;
const thumbWidth = Math.max(1, Math.round(width / 2));
const thumbHeight = Math.max(1, Math.round(height / 2));
const [thumb, browse] = await Promise.all([
makeJpeg(thumbWidth, thumbHeight),
makeJpeg(width, height),
]);
await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg');
await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg');
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-viewer-e2e-${alias}`,
categoryId: options.categoryId ?? null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: browse.length,
width,
height,
uploadedAt: options.publishedAt,
publishedAt: options.publishedAt,
processingState: 'ready',
visibility: 'published',
entityVersion: 2,
},
});
await prisma.photo_variants.createMany({
data: [
{
photoId,
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
bytes: thumb.length,
version: 1,
width: thumbWidth,
height: thumbHeight,
},
{
photoId,
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
bytes: browse.length,
version: 1,
width,
height,
},
],
});
return photoId;
}
/** cn→ov 种子同步(P2 Task 6):先打 cn bootstrap(当前态快照事件写回 outbox,
* 优先级 normal),再 deliverPendingOnce 经真实发送管线(HMAC 签名
* content-batches)排空至 ov 副本。幂等可重入:同版本新 eventId 在 ov 侧按
* 当前态重断言应用;与常驻 dev:sync 发送器并发亦安全(eventId 幂等)。 */
export async function flushSync(albumId: string): Promise<void> {
const internalKey = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
const rawBody = JSON.stringify({ albumId });
const timestamp = Math.floor(Date.now() / 1000).toString();
const cnBase = (process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '');
const bootstrap = await fetch(`${cnBase}/v1/internal/sync/bootstrap`, {
method: 'POST',
headers: {
'content-type': 'application/json',
[SYNC_TIMESTAMP_HEADER]: timestamp,
[SYNC_SIGNATURE_HEADER]: signSyncBody(internalKey, rawBody),
},
body: rawBody,
});
if (!bootstrap.ok) {
throw new Error(
`flushSync bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`,
);
}
await flushOutbox();
}
/** 仅排空 outbox(P4b Task 4 embed e2e):slot.updated 事件不随相册 bootstrap
* 走(bootstrap 清单恒不含 slot-state),展示位创建/绑定的同步须单独排空。 */
export async function flushOutbox(): Promise<void> {
await deliverPendingOnce({
prisma,
ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081',
internalKey: process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me',
});
}
// ── P4b Task 4 展示位种子(经 cn admin API 真实路径,非直插行)──
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
const seededSlotIds: string[] = [];
let adminTokenPromise: Promise<string> | null = null;
/** seeded admin 幂等 + 登录(journey.spec 同款;会话进程内复用)。 */
async function loginAdmin(): Promise<string> {
if (adminTokenPromise === null) {
adminTokenPromise = (async () => {
await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD);
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/auth/password`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD }),
},
);
if (!res.ok) {
throw new Error(`embed seed 登录失败(${res.status})`);
}
return ((await res.json()) as { sessionToken: string }).sessionToken;
})();
}
return adminTokenPromise;
}
/** 经 cn admin API 创建展示位(未绑定态;settings 缺省),登记待清理。 */
export async function createEmbedSlot(name: string): Promise<{ id: string; token: string }> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots`,
{
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
},
);
if (!res.ok) {
throw new Error(`createEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
const body = (await res.json()) as { id: string; publicToken: string };
seededSlotIds.push(body.id);
return { id: body.id, token: body.publicToken };
}
/** 经 cn admin API 更新展示位(绑定/换绑相册、启停、设置——body 直传契约形状)。 */
export async function updateEmbedSlot(id: string, body: Record<string, unknown>): Promise<void> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots/${id}`,
{
method: 'PATCH',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(body),
},
);
if (!res.ok) {
throw new Error(`updateEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
}
/** 模拟已分享深链的照片下线(Task 8 e2e):软删(visibility=deleted +
* entityVersion 自增)——硬删会让 cn 行缺席、bootstrap 快照无从发墓碑,ov 副本
* 将永远留着旧发布行;软删 + flushSync 让 photo.deleted 事件走真实管线撤下。
* 变体行/MinIO 对象保留(公开读只看 photos 行;行清理归 cleanupSeededAlbums)。 */
export async function deleteSeededPhoto(photoId: string): Promise<void> {
await prisma.photos.update({
where: { id: photoId },
data: { visibility: 'deleted', entityVersion: { increment: 1 } },
});
}
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
* MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀,
* dev 桶内残余有界(同 api e2e 约定)。 */
export async function cleanupSeededAlbums(): Promise<void> {
try {
// 展示位种子(P4b):绑定态 slot.updated 行随相册圈定(albumId 在种子相册集)
// 已被下方 outbox 清理覆盖;未绑定创建行以 albumId='' 占位——仅清「未投递」
// 残留(已投递行 inert;未投递残留会污染后续 deliverPendingOnce 排空判定)。
if (seededSlotIds.length > 0) {
await prisma.outbox_events.deleteMany({
where: { type: 'slot.updated', albumId: '', deliveredAt: null },
});
await prisma.embed_slots.deleteMany({ where: { id: { in: seededSlotIds } } });
seededSlotIds.length = 0;
}
if (seededAlbumIds.length > 0) {
const photos = await prisma.photos.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },
});
if (photos.length > 0) {
await prisma.photo_variants.deleteMany({
where: { photoId: { in: photos.map((p) => p.id) } },
});
}
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
// flushSync 经 outbox 投递的快照事件行(已投递/未投递一并清——未投递
// 残留会污染后续 deliverPendingOnce 的排空判定轮次)。
await prisma.outbox_events.deleteMany({
where: { albumId: { in: seededAlbumIds } },
});
const categories = await prisma.categories.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },
});
const categoryIds = categories.map((c) => c.id);
if (categoryIds.length > 0) {
await prisma.category_translations.deleteMany({ where: { categoryId: { in: categoryIds } } });
}
await prisma.categories.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.album_assets.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.album_translations.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.albums.deleteMany({ where: { id: { in: seededAlbumIds } } });
seededAlbumIds.length = 0;
}
} finally {
await prisma.$disconnect();
}
}