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>
This commit is contained in:
浅念
2026-09-20 23:02:07 +08:00
parent ddb4c8175f
commit 8f2c21d25a
16 changed files with 1433 additions and 5 deletions
@@ -0,0 +1,339 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { ApiError, type PublicEmbedPhoto, type PublicEmbedSlot } from '../api/client';
import { postEmbedMessage } from '../embed/postMessage';
import {
clampEmbedHeight,
clampEmbedIntervalMs,
classifyEmbedResponse,
mergePhotoQueue,
pollIntervalAfterFailures,
resolveEmbedLocale,
} from '../embed/feed';
import { useEmbedFeed, type EmbedFeed, type UseEmbedFeedOptions } from '../embed/useEmbedFeed';
// 嵌入轮播数据流(P4b Task 4):纯函数矩阵(A22 旧响应丢弃/队列差分/参数夹取/
// 退避)+ useEmbedFeed 编排(304 ETag 链路、404 静默 collapse、可见性暂停恢复、
// 轮播切换门控)。postMessage 模块替身(jsdom 无父窗,postEmbedMessage 断言上行
// 载荷;onEmbedExpand 捕获 handler 供用例模拟宿主 expand);IntersectionObserver/
// ResizeObserver 在 jsdom 缺席,composable 已按特性探测跳过(高度上报走
// clampEmbedHeight 纯函数覆盖)。
const expandHandlers = vi.hoisted(() => new Set<() => void>());
vi.mock('../embed/postMessage', () => ({
postEmbedMessage: vi.fn(),
embedParentOrigin: vi.fn(() => null),
onEmbedExpand: vi.fn((handler: () => void) => {
expandHandlers.add(handler);
return () => expandHandlers.delete(handler);
}),
}));
const mockedPost = vi.mocked(postEmbedMessage);
function makePhoto(id: string, url?: string): PublicEmbedPhoto {
return {
photoId: id,
thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: '2026-10-01T00:00:00.000Z' },
width: 240,
height: 150,
};
}
function makeSlot(overrides: Partial<PublicEmbedSlot> = {}): PublicEmbedSlot {
return {
slotToken: 'slottest0000000000000000aa',
configVersion: 1,
album: {
publicToken: 'tokfixture0000000000aaaa',
title: '嵌入测试相册',
defaultLocale: 'zh-CN',
},
photos: [makePhoto('p1'), makePhoto('p2')],
...overrides,
};
}
// —— 纯函数矩阵 ——
describe('embed feed pure helpers', () => {
it('clamps the interval override to 3000-30000 and passes through null', () => {
expect(clampEmbedIntervalMs(null)).toBeNull();
expect(clampEmbedIntervalMs(Number.NaN)).toBeNull();
expect(clampEmbedIntervalMs(100)).toBe(3000);
expect(clampEmbedIntervalMs(5000)).toBe(5000);
expect(clampEmbedIntervalMs(120000)).toBe(30000);
});
it('clamps reported heights to 60-640 and rejects non-finite values', () => {
expect(clampEmbedHeight(null)).toBeNull();
expect(clampEmbedHeight(Number.NaN)).toBeNull();
expect(clampEmbedHeight(10)).toBe(60);
expect(clampEmbedHeight(321.6)).toBe(322);
expect(clampEmbedHeight(2000)).toBe(640);
});
it('resolves the UI locale from lang param with album defaultLocale fallback', () => {
expect(resolveEmbedLocale('auto', 'en')).toBe('en');
expect(resolveEmbedLocale('zh-CN', 'en')).toBe('zh-CN');
expect(resolveEmbedLocale('bogus', 'en')).toBe('en');
expect(resolveEmbedLocale('auto', 'fr')).toBe('zh-CN'); // 不支持回退
expect(resolveEmbedLocale('auto', undefined)).toBe('zh-CN');
});
it('discards a response whose dispatch seq is stale (newer request in flight)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 1, currentSeq: 2, dispatchVersion: 1, currentVersion: 1, responseVersion: 1 }),
).toBe('discard');
});
it('discards a response whose configVersion is below the locally accepted one (A22 旧响应)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 1, currentVersion: 3, responseVersion: 2 }),
).toBe('discard');
});
it('merges on equal configVersion and resets on higher (or first load)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 2, currentVersion: 2, responseVersion: 2 }),
).toBe('merge');
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 2, currentVersion: 2, responseVersion: 5 }),
).toBe('reset');
expect(
classifyEmbedResponse({ dispatchSeq: 1, currentSeq: 1, dispatchVersion: null, currentVersion: null, responseVersion: 1 }),
).toBe('reset');
});
it('merges the photo queue: new photos append at the tail, missing ones drop', () => {
const current = [makePhoto('a'), makePhoto('b'), makePhoto('c')];
const incoming = [makePhoto('c'), makePhoto('d'), makePhoto('b')];
const merged = mergePhotoQueue(current, incoming);
// 幸存保持现有顺序(展示不被打断),新照片按服务端顺序追加队尾,
// 服务端缺席(a)淘汰。
expect(merged.photos.map((p) => p.photoId)).toEqual(['b', 'c', 'd']);
expect(merged.added).toEqual(['d']);
expect(merged.removed).toEqual(['a']);
});
it('refreshes thumb URLs of survivors and reports removals', () => {
const current = [makePhoto('a', 'https://img.test/old-a.jpg'), makePhoto('gone')];
const incoming = [makePhoto('a', 'https://img.test/new-a.jpg')];
const merged = mergePhotoQueue(current, incoming);
expect(merged.photos.map((p) => p.photoId)).toEqual(['a']);
expect(merged.photos[0]!.thumb.url).toBe('https://img.test/new-a.jpg');
expect(merged.removed).toEqual(['gone']);
});
it('backs off the poll interval only once failures reach the cap', () => {
expect(pollIntervalAfterFailures(0, 30000)).toBe(30000);
expect(pollIntervalAfterFailures(4, 30000)).toBe(30000);
expect(pollIntervalAfterFailures(5, 30000)).toBe(150000);
expect(pollIntervalAfterFailures(9, 30000)).toBe(150000);
});
});
// —— useEmbedFeed 编排 ——
const mounted: VueWrapper[] = [];
function mountFeed(options: { fetcher: Mock; langParam?: string; intervalParamMs?: number | null }): EmbedFeed {
let feed!: EmbedFeed;
const Host = defineComponent({
setup() {
feed = useEmbedFeed({
slotToken: 'slote2etest0000000000aa',
langParam: options.langParam ?? 'auto',
intervalParamMs: options.intervalParamMs ?? null,
rootEl: ref(null),
fetcher: options.fetcher as UseEmbedFeedOptions['fetcher'],
});
return () => null;
},
});
mounted.push(mount(Host));
return feed;
}
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
function setHidden(hidden: boolean): void {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
}
describe('useEmbedFeed orchestration', () => {
beforeEach(() => {
vi.useFakeTimers();
mockedPost.mockClear();
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
setHidden(false);
});
it('loads the slot, passes etag on subsequent polls and treats 304 as no change', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledWith('slote2etest0000000000aa', { lang: 'auto', etag: null });
expect(feed.status).toBe('ready');
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
expect(feed.album?.title).toBe('嵌入测试相册');
expect(feed.resolvedLocale).toBe('zh-CN'); // auto → defaultLocale
// 下一轮带上 If-None-Match;304(data null)→ 队列零变化、不重置版本。
fetcher.mockResolvedValue({ data: null, etag: '"e1"' });
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher).toHaveBeenLastCalledWith('slote2etest0000000000aa', { lang: 'auto', etag: '"e1"' });
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
expect(feed.status).toBe('ready');
});
it('merges an equal-version response: new photo appended at tail, urls refreshed', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
fetcher.mockResolvedValue({
data: makeSlot({
photos: [makePhoto('p3', 'https://img.test/p3-new.jpg'), makePhoto('p1', 'https://img.test/p1-new.jpg'), makePhoto('p2', 'https://img.test/p2-new.jpg')],
}),
etag: '"e2"',
});
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-new.jpg');
expect(feed.index).toBe(0); // 合并不打断当前展示
});
it('fully resets when configVersion rises (rebind, A22)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
feed.postResize(); // no-op(rootEl null)——仅确认不抛
fetcher.mockResolvedValue({
data: makeSlot({
configVersion: 2,
album: { publicToken: 'tokother00000000000000bbbb', title: '换绑相册', defaultLocale: 'en' },
photos: [makePhoto('q1')],
}),
etag: '"e2"',
});
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.photos.map((p) => p.photoId)).toEqual(['q1']); // 旧照片不残留
expect(feed.album?.title).toBe('换绑相册');
expect(feed.resolvedLocale).toBe('en'); // 相册 defaultLocale 变化生效
});
it('discards a stale in-flight response (seq guard)', async () => {
let resolveFirst!: (value: { data: PublicEmbedSlot; etag: string }) => void;
let resolveSecond!: (value: { data: PublicEmbedSlot; etag: string }) => void;
const fetcher = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve; }));
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0); // 首次 dispatch(in flight)
// 可见性恢复触发第二次 dispatch(首次仍在途)→ seq 前进。
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
resolveSecond({ data: makeSlot({ configVersion: 2, photos: [makePhoto('new1'), makePhoto('new2')] }), etag: '"e2"' });
await vi.advanceTimersByTimeAsync(0);
resolveFirst({ data: makeSlot({ photos: [makePhoto('stale1')] }), etag: '"e1"' });
await vi.advanceTimersByTimeAsync(0);
// 旧响应(v1、发起更早)迟到 → 丢弃,不串入新集合。
expect(feed.photos.map((p) => p.photoId)).toEqual(['new1', 'new2']);
});
it('goes silent on 404: no UI state, collapse posted to the host loader', async () => {
const fetcher = vi.fn().mockRejectedValue(new ApiError('NOT_FOUND', '展示位不存在或不可用', 404, null));
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(feed.status).toBe('gone');
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' });
// gone 后彻底停止轮询。
await vi.advanceTimersByTimeAsync(300_000);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('backs off after five consecutive failures and recovers on success', async () => {
const failure = new ApiError('INTERNAL_ERROR', 'boom', 500, null);
const fetcher = vi.fn().mockRejectedValue(failure);
mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0); // 第 1 次(failures=1)
for (let i = 0; i < 4; i++) await vi.advanceTimersByTimeAsync(30_000); // 第 2-5 次
expect(fetcher).toHaveBeenCalledTimes(5);
await vi.advanceTimersByTimeAsync(30_000); // 退避中(150s 间隔)
expect(fetcher).toHaveBeenCalledTimes(5);
await vi.advanceTimersByTimeAsync(120_000); // 累计 150s → 第 6 次(仍失败)
expect(fetcher).toHaveBeenCalledTimes(6);
fetcher.mockResolvedValue({ data: makeSlot(), etag: '"ok"' });
await vi.advanceTimersByTimeAsync(150_000); // 第 7 次(成功,failures 归零)
await vi.advanceTimersByTimeAsync(30_000); // 恢复正常间隔 → 第 8 次
expect(fetcher).toHaveBeenCalledTimes(8);
});
it('pauses polling while hidden and refreshes immediately on return (A23)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
const callsBeforeHide = fetcher.mock.calls.length;
setHidden(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(120_000);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide); // 隐藏期间零轮询
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide + 1); // 恢复立即校准一次
});
it('advances the carousel on intervalMs and freezes it while hidden', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher, intervalParamMs: 3000 });
await vi.advanceTimersByTimeAsync(0);
expect(feed.index).toBe(0);
await vi.advanceTimersByTimeAsync(3000);
expect(feed.index).toBe(1);
await vi.advanceTimersByTimeAsync(3000);
expect(feed.index).toBe(0); // 两张循环
setHidden(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.index).toBe(0); // 暂停期间不切换
});
it('collapse stops timers and expand from the parent resumes with a resize repost', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher, intervalParamMs: 3000 });
await vi.advanceTimersByTimeAsync(0);
const callsAfterLoad = fetcher.mock.calls.length;
feed.requestCollapse();
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' });
await vi.advanceTimersByTimeAsync(60_000);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad); // 收起期间零轮询
// 宿主把手 expand(onEmbedExpand 捕获的 handler):恢复(立即校准 + 重发高度,
// rootEl null → resize 跳过不抛)。
expandHandlers.forEach((handler) => handler());
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad + 1);
});
});