2026-09-18 19:52:27 +08:00
|
|
|
|
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();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.photo-viewer')).toBeVisible();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
for (let i = 0; i < 3; i++) {
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await page.locator('.action-bar .like-slot .action-btn').click();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 防抖 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();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.photo-viewer')).toBeVisible();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
await expect(page.locator('.like-count')).toHaveText('3');
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await page.locator('.viewer-close').click();
|
|
|
|
|
|
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
2026-09-18 19:52:27 +08:00
|
|
|
|
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();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.photo-viewer')).toBeVisible();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
} 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();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.photo-viewer')).toBeVisible();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.photo-viewer')).toBeVisible();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
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));
|
2026-09-21 23:10:58 +08:00
|
|
|
|
await expect(page.locator('.sort-tabs .sort-tab').nth(1)).toHaveClass(/active/);
|
2026-09-18 19:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 切回最新:原顺序恢复(冷快照语义,各自独立)。
|
|
|
|
|
|
await page.getByRole('button', { name: '最新' }).click();
|
|
|
|
|
|
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer));
|
|
|
|
|
|
|
|
|
|
|
|
// 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。
|
|
|
|
|
|
await page.locator('.cell').first().click();
|
2026-09-21 23:10:58 +08:00
|
|
|
|
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();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 回填)。
|
2026-09-22 18:56:38 +08:00
|
|
|
|
await expect(page1.locator('.view-count')).toContainText('1 次浏览', { timeout: 15_000 });
|
2026-09-18 19:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 刷新:visitorId 持久 + 窗口去重 → 不再计数。
|
|
|
|
|
|
await page1.reload();
|
|
|
|
|
|
await expect(page1.locator('.cell').first()).toBeVisible();
|
2026-09-22 18:56:38 +08:00
|
|
|
|
await expect(page1.locator('.view-count')).toContainText('1 次浏览');
|
2026-09-18 19:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 第二个全新上下文:新 visitorId → 再计数。
|
|
|
|
|
|
const ctx2 = await browser.newContext();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const page2 = await ctx2.newPage();
|
|
|
|
|
|
await openAlbum(page2, token);
|
2026-09-22 18:56:38 +08:00
|
|
|
|
await expect(page2.locator('.view-count')).toContainText('2 次浏览', { timeout: 15_000 });
|
2026-09-18 19:52:27 +08:00
|
|
|
|
} 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();
|
|
|
|
|
|
|
2026-09-21 21:22:36 +08:00
|
|
|
|
// 顶栏「⋯」菜单内开关减少动画(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');
|
2026-09-18 19:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 他者点赞:无气泡(动画停用),计数照常被 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');
|
|
|
|
|
|
});
|