Files
photo-live/apps/viewer/e2e/likes.spec.ts
T
浅念 8c4906c2f2 feat(embed,ui04): E07 acceptance — matrix completion, hostile-CSS hardening, measurements and record closure
- E07 e2e (7 cases): 4-viewport fixed size, narrow shrink + dual-slot 12px stack, single/static + empty state + two-photo loop copies, broken-thumb drop, reducedMotion stop with manual arrows, forged postMessage rejected, hostile CSS (!important box-sizing/border) with inline-important countermeasures + duplicate script dedup
- loader v2: geometry-critical properties upgraded to inline !important (beats host stylesheet !important)
- pre-existing fixes: likes.spec view-count assertion vs concurrent session's AlbumHeader unicode icons (toContainText); embed-v2 host port moved to 3998 (3999 collides with embed-crossorigin under parallel full runs); prettier formatting of two concurrent-session files (formatting-only)
- evidence: matrix EMB-A01..A16 closed in acceptance record (mechanism-level items honestly marked), measurements (loader v2 9885B/gzip 3959B, first-load 1 GET + 1 WS, DOM<=16), refreshed screenshots/recordings, master plan UI-04 note
- regression: root lint/typecheck/test 12 packages green; embed suites 43/43; admin embed-slots 5/5; journey pre-existing env failure reproduced at clean HEAD (registered)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:56:38 +08:00

265 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('.photo-viewer')).toBeVisible();
for (let i = 0; i < 3; i++) {
await page.locator('.action-bar .like-slot .action-btn').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('.photo-viewer')).toBeVisible();
await expect(page.locator('.like-count')).toHaveText('3');
await page.locator('.viewer-close').click();
await expect(page.locator('.photo-viewer')).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('.photo-viewer')).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('.photo-viewer')).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('.photo-viewer')).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-tabs .sort-tab').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('.photo-viewer')).toBeVisible();
await expect(page.locator('.sort-tabs .sort-tab').first()).toBeDisabled();
await expect(page.locator('.sort-tabs .sort-tab').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')).toContainText('1 次浏览', { timeout: 15_000 });
// 刷新:visitorId 持久 + 窗口去重 → 不再计数。
await page1.reload();
await expect(page1.locator('.cell').first()).toBeVisible();
await expect(page1.locator('.view-count')).toContainText('1 次浏览');
// 第二个全新上下文:新 visitorId → 再计数。
const ctx2 = await browser.newContext();
try {
const page2 = await ctx2.newPage();
await openAlbum(page2, token);
await expect(page2.locator('.view-count')).toContainText('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();
// 顶栏「⋯」菜单内开关减少动画(UI-02 V01;localStorage 偏好持久)。
await page.locator('.topbar .icon-btn').click();
await page.locator('.prefs-item').click();
await expect(page.locator('.prefs-item')).toHaveAttribute('aria-checked', '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');
});