Files
photo-live/apps/viewer/e2e/likes.spec.ts
T
浅念 cdead49838 feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 19:52:27 +08:00

264 lines
12 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 { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
deleteSeededPhoto,
flushSync,
seedAlbum,
seedPhoto,
} from './helpers';
// Task 10 互动化 Playwright(真实后端 + 真实 WS/点赞/浏览管线):
// · 大图真实点赞 ×3 → 计数权威 3 → 刷新持久;
// · 气泡自身过滤(双真实浏览器上下文:他赞出气泡、己赞不出);
// · WS 不可达(__viewerWsUrl 死端口注入;setOffline 不切断已建 WS,见用例注)
// → 三败降级轮询校准计数 → 翻回真实 URL 重连 open;
// · 照片下线 WS 推送 → 序列即时移除 + 大图回退邻居(deleteSeededPhoto+flushSync);
// · 排序切换(最新/热门):API 直推 B 点赞 → 热门置顶;大图开时 chips 禁用;
// · 浏览上报:全新上下文计数 +1,刷新不重复,第二上下文再 +1;
// · 减少动画:他赞无气泡、计数照常校准。
// e2e 注入点:__viewerRealtimePollMs / __viewerWsRetryBaseMs / __viewerBubbleMs /
// __viewerWsUrl(沿用 __viewerPollMs 约定);连接态锚 .activity[data-connection]。
const OV_BASE = 'http://127.0.0.1:8081/v1';
test.afterAll(async () => {
await cleanupSeededAlbums();
});
/** 直推一个点赞批次(他者会话面,绕开 UI 时序):返回权威 total。 */
async function apiLike(token: string, photoId: string, sessionId: string, delta: number): Promise<number> {
const res = await fetch(`${OV_BASE}/public/albums/${token}/photos/${photoId}/likes`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, batchId: randomUUID(), delta }),
});
expect(res.status).toBe(200);
return ((await res.json()) as { total: number }).total;
}
/** 种「互动相册」:n 张已发布照片(publishedAt 逐张递减,最新序确定)。 */
async function seedInteractiveAlbum(
alias: string,
photos: number,
): Promise<{ albumId: string; token: string; photoIds: string[] }> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: `互动测试相册 ${alias}` }],
});
const base = Date.now();
const photoIds: string[] = [];
for (let i = 0; i < photos; i++) {
photoIds.push(
await seedPhoto(album.id, `${alias}-p${i}`, {
width: 480,
height: 300,
publishedAt: new Date(base - (i + 1) * 60_000),
}),
);
}
await flushSync(album.id);
return { albumId: album.id, token: album.publicToken, photoIds };
}
async function openAlbum(page: Page, token: string, init?: () => void): Promise<void> {
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
if (init) await page.addInitScript(init);
await page.goto(`/a/${token}`);
await expect(page.locator('.cell').first()).toBeVisible();
}
test('lightbox likes accumulate to 3 and persist across reload', async ({ page }) => {
test.setTimeout(90_000);
const { token } = await seedInteractiveAlbum('likebox', 1);
await openAlbum(page, token);
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
for (let i = 0; i < 3; i++) {
await page.locator('.control.like-button').click();
}
// 防抖 500ms 冲批 → 服务端权威校准 3(响应/WS burst 先后到达均为 3)。
await expect(page.locator('.like-count')).toHaveText('3');
// 等批次真正落服务端(乐观显示先于冲批——刷新前必须确认已持久)。
await expect
.poll(async () => {
const res = await fetch(`${OV_BASE}/public/albums/${token}/photos`);
const json = (await res.json()) as { items: Array<{ likeCount: number }> };
return json.items[0]?.likeCount ?? 0;
})
.toBe(3);
// 刷新后从服务端列表回归:深链自动重开大图(URL 仍停在该照片),计数 3;
// 关闭后格子计数亦 3(sessionId 会话内持久,幂等面不变)。
await page.reload();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.like-count')).toHaveText('3');
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('3');
});
test('others burst bubbles on my page; my own like does not', async ({ browser, page }) => {
test.setTimeout(120_000);
const { token } = await seedInteractiveAlbum('bubbles', 1);
const ctx2 = await browser.newContext();
const page2 = await ctx2.newPage();
try {
await openAlbum(page, token, () => {
(window as unknown as { __viewerBubbleMs?: number }).__viewerBubbleMs = 8000;
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
await openAlbum(page2, token);
await expect(page2.locator('.activity[data-connection="open"]')).toBeVisible();
// 己赞:burst 的 originBatchIds 全属本会话 → 过滤,不出气泡。
await page.locator('.cell-like').first().click();
await page.waitForTimeout(2500); // 冲批(500ms)+广播合并窗(500ms)+裕量
await expect(page.locator('.like-bubble')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('1');
// 他赞(page2 真实点击):page1 应出 +1 气泡,点击直达该照片大图。
await page2.locator('.cell-like').first().click();
await expect(page.locator('.like-bubble')).toHaveCount(1);
await expect(page.locator('.like-bubble .bubble-delta')).toHaveText('+1');
await expect(page.locator('.cell-like .count').first()).toHaveText('2');
await page.locator('.like-bubble').click();
await expect(page.locator('.lightbox')).toBeVisible();
} finally {
await ctx2.close();
}
});
test('WS loss degrades to polling and recovers to open with calibrated counts', async ({ page }) => {
test.setTimeout(150_000);
const { token, photoIds } = await seedInteractiveAlbum('degrade', 1);
const photoId = photoIds[0]!;
// 注:Chromium 的 setOffline 不切断已建立的 WS(实测保持 open),中途改 URL 也
// 只影响后续连接——改为「首连即不可达」驱动降级:__viewerWsUrl 指向死端口
// (error+close 3ms、无 open),HTTP 面保持可用,恰好可断言降级轮询的校准职责;
// 运行中翻回真实 URL 验证后台重连恢复。
await openAlbum(page, token, () => {
(window as unknown as Record<string, string>).__viewerWsUrl = 'ws://127.0.0.1:9/dead';
(window as unknown as Record<string, number>).__viewerWsRetryBaseMs = 300;
(window as unknown as Record<string, number>).__viewerRealtimePollMs = 1000;
});
// 三次连接失败(3ms 内 close,退避 300/600/1200ms)→ degraded(轮询接管,此处 1s)。
await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible({
timeout: 30_000,
});
// 降级期间他者经 API 点赞 2:WS 已死,靠轮询第 1 页重取把计数对齐到 2。
await apiLike(token, photoId, 'e2e-degrade-other', 2);
await expect(page.locator('.cell-like .count').first()).toHaveText('2', { timeout: 15_000 });
await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible(); // 仍降级(无气泡补播面)
// 翻回真实 URL:后台重连成功 → open,计数维持权威 2。
await page.evaluate(() => {
(window as unknown as Record<string, string>).__viewerWsUrl = '';
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('.cell-like .count').first()).toHaveText('2');
});
test('hidden photo disappears from the feed and closes the lightbox via WS push', async ({ page }) => {
test.setTimeout(120_000);
const { albumId, token, photoIds } = await seedInteractiveAlbum('remove', 3);
await openAlbum(page, token);
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
await expect(page.locator('.cell')).toHaveCount(3);
// 大图停在第 2 张:下线推送应回退邻居并收口(closeLightbox 路径)。
await page.locator('.cell').nth(1).click();
await expect(page.locator('.lightbox')).toBeVisible();
await deleteSeededPhoto(photoIds[1]!);
await flushSync(albumId); // photo.deleted 走真实管线 → ov 撤下 + WS 房间广播
// 推送到达:列表无刷新即时 2 张 + 下线提示;大图回退到后邻(第 3 张)仍可看。
await expect(page.locator('.cell')).toHaveCount(2, { timeout: 20_000 });
await expect(page.locator('.removed-notice')).toBeVisible();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
});
test('sort toggle switches latest/hot snapshots and is disabled in the lightbox', async ({ page }) => {
test.setTimeout(120_000);
const { token, photoIds } = await seedInteractiveAlbum('sort', 2);
const [newer, older] = photoIds as [string, string];
await openAlbum(page, token);
// 最新序:publishedAt 新者(第 1 张种)在前。
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer));
// 他者会话直推 older ×10 → 热门序首取即新鲜快照:older 置顶。
await apiLike(token, older, 'e2e-hot-session', 10);
await page.getByRole('button', { name: '热门' }).click();
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(older));
await expect(page.locator('.sort .chip').nth(1)).toHaveClass(/active/);
// 切回最新:原顺序恢复(冷快照语义,各自独立)。
await page.getByRole('button', { name: '最新' }).click();
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer));
// 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.sort .chip').first()).toBeDisabled();
await expect(page.locator('.sort .chip').nth(1)).toBeDisabled();
});
test('visit counting: fresh context counts once, reload is idempotent, second context counts', async ({
browser,
}) => {
test.setTimeout(120_000);
const { token } = await seedInteractiveAlbum('visits', 1);
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
try {
await openAlbum(page1, token);
// 首访计数(上报成功后以权威 viewCount 回填)。
await expect(page1.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
// 刷新:visitorId 持久 + 窗口去重 → 不再计数。
await page1.reload();
await expect(page1.locator('.cell').first()).toBeVisible();
await expect(page1.locator('.view-count')).toHaveText('1');
// 第二个全新上下文:新 visitorId → 再计数。
const ctx2 = await browser.newContext();
try {
const page2 = await ctx2.newPage();
await openAlbum(page2, token);
await expect(page2.locator('.view-count')).toHaveText('2', { timeout: 15_000 });
} finally {
await ctx2.close();
}
} finally {
await ctx1.close();
}
});
test('reduced motion suppresses bubbles while counts still calibrate', async ({ page }) => {
test.setTimeout(120_000);
const { token, photoIds } = await seedInteractiveAlbum('motion', 1);
await openAlbum(page, token, () => {
(window as unknown as Record<string, number>).__viewerBubbleMs = 8000;
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
// 顶栏开关减少动画(localStorage 偏好持久)。
await page.locator('.motion-toggle').click();
await expect(page.locator('.motion-toggle')).toHaveAttribute('aria-pressed', 'true');
// 他者点赞:无气泡(动画停用),计数照常被 burst 校准。
await apiLike(token, photoIds[0]!, 'e2e-motion-other', 1);
await page.waitForTimeout(2500); // 广播合并窗 + 裕量:给「本该出气泡」的窗口
await expect(page.locator('.like-bubble')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('1');
});