40f8f37ca4
- viewer playwright.config: third webServer boots admin dev (5176, reuseExisting) so one config serves both frontends - journey.spec.ts: admin UI (login, zh+en album, real-jpeg banner via file chooser, category, staffB invite by account search, logout) -> staffB API uploads x3 (init/presign-PUT/complete) -> worker PublishService.processPhoto x3 -> public list verify -> viewer UI (banner bytes, zh->en switch, waterfall 3, chip filter 1, lightbox zoom/close, deep link) -> admin hide -> viewer 2 + hidden deep-link 404 notice -> empty album state - cleanup: journey album domain rows + staffB account via Prisma Co-Authored-By: Claude Code <noreply@anthropic.com>
464 lines
20 KiB
TypeScript
464 lines
20 KiB
TypeScript
// apps/viewer/e2e/journey.spec.ts —— Task 11 跨端联调冒烟(Playwright 全流程,
|
||
// 真实后端 + 真实 MinIO/MySQL/Redis compose)。ONE 剧本串起三端:
|
||
// ① admin UI(5176):seeded admin 登录 → 建相册 A(zh+en)→ banner 真实
|
||
// jpeg 走 file chooser(init→presign PUT→complete→预览,R09)→ 建分类
|
||
// (zh+en)→ 账号前缀搜索邀 staffB → 登出;
|
||
// ② staffB 经 API(UI 上传属 P3):登录 → init×3(其一入分类)→ presign PUT
|
||
// 实写 → complete×3 → worker PublishService.processPhoto×3(services/api
|
||
// full-chain 同款引导;BullMQ 队列消费路径由 api e2e 覆盖,此处直调 worker
|
||
// 发布事务)→ 公开列表恰 3 张(verify ready);
|
||
// ③ viewer UI(5175):/a/{token} banner 渲染 → zh 信息 → 切 en 内容随切 →
|
||
// 瀑布流 3 张 → 分类 chip 过滤 1 张 → 大图开/缩放/关 → 深链直达;
|
||
// ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 1 张 → viewer 重载
|
||
// 剩 2 张 → 被隐深链 404 本地化提示;
|
||
// ⑤ 空相册 B(admin API 建,无照片):viewer 空态提示「照片即将上线」。
|
||
// 数据清理:afterAll 按 viewer helpers 同序直删(含 album_members/upload_sessions/
|
||
// photo_jobs/outbox_events 等 API 链路落库行)+ staffB 账号域行。
|
||
import { randomBytes } from 'node:crypto';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { config } from 'dotenv';
|
||
import sharp from 'sharp';
|
||
import { expect, test, type Page } from '@playwright/test';
|
||
import { PrismaClient } from '@photo/db-cn';
|
||
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
||
import { hashPassword } from '../../../services/api/src/auth/password';
|
||
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
|
||
import { PublishService } from '../../../services/worker/src/publish.service';
|
||
|
||
// 仓库根 .env 唯一来源(同 helpers.ts 约定):PrismaClient/createStorageFromEnv
|
||
// 构造前先加载。API 腿直连 8080(服务端到服务端,无 CORS 面)。
|
||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
||
config({ path: join(repoRoot, '.env') });
|
||
|
||
const ADMIN_BASE = 'http://127.0.0.1:5176';
|
||
const API_BASE = 'http://127.0.0.1:8080/v1';
|
||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||
const STAFF_ACCOUNT = 'journey-staff';
|
||
const STAFF_PASSWORD = 'journey-staff-secret-1';
|
||
|
||
const prisma = new PrismaClient();
|
||
const storage: MinioStorageAdapter = createStorageFromEnv();
|
||
|
||
/** 本轮创建的相册 id(UI 建 A + API 建 B),afterAll 直删。 */
|
||
const journeyAlbumIds: string[] = [];
|
||
|
||
/** JSON API 便捷封装(staffB/admin 腿)。 */
|
||
async function apiFetch(
|
||
path: string,
|
||
init: { method?: string; token?: string; body?: unknown } = {},
|
||
): Promise<{ status: number; json: Record<string, unknown> }> {
|
||
const res = await fetch(`${API_BASE}${path}`, {
|
||
method: init.method ?? 'GET',
|
||
headers: {
|
||
...(init.body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
|
||
},
|
||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||
});
|
||
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||
return { status: res.status, json };
|
||
}
|
||
|
||
/** 测试内生成真实 JPEG(sharp;不同尺寸兼作管理列表行锚点)。 */
|
||
async function makeJpeg(
|
||
width: number,
|
||
height: number,
|
||
rgb: { r: number; g: number; b: number },
|
||
): Promise<Buffer> {
|
||
return sharp({
|
||
create: { width, height, channels: 3, background: rgb },
|
||
})
|
||
.jpeg({ quality: 85 })
|
||
.toBuffer();
|
||
}
|
||
|
||
/** admin UI 登录(镜像 apps/admin e2e loginAsAdmin;跨端绝对 baseURL)。 */
|
||
async function loginAdminUi(page: Page): Promise<void> {
|
||
await page.goto(`${ADMIN_BASE}/#/auth/login`);
|
||
await page.getByPlaceholder('请输入账号').fill(ADMIN_ACCOUNT);
|
||
await page.getByPlaceholder('请输入密码', { exact: false }).fill(ADMIN_PASSWORD);
|
||
await page.getByRole('button', { name: '登录' }).click();
|
||
await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 });
|
||
}
|
||
|
||
// admin UI 选择器一律 data-test(同 apps/admin e2e 约定);viewer 侧走 CSS 类名。
|
||
test.use({ testIdAttribute: 'data-test' });
|
||
|
||
test.describe.serial('cross-frontend journey (admin → api/worker → viewer)', () => {
|
||
let staffBId: string;
|
||
let staffBToken: string;
|
||
let adminToken: string;
|
||
let albumId: string;
|
||
let publicToken: string;
|
||
let emptyToken: string;
|
||
let categoryId: string;
|
||
const photoIds: string[] = [];
|
||
|
||
test.beforeAll(async () => {
|
||
// seeded admin 幂等(admin e2e global-setup 同款;viewer 侧自足)。
|
||
await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||
// staffB 可登录账号:先清历史残余(重跑确定性),再直种(argon2 真哈希)。
|
||
const existing = await prisma.users.findUnique({
|
||
where: { account: STAFF_ACCOUNT },
|
||
select: { id: true },
|
||
});
|
||
if (existing) {
|
||
await prisma.sessions.deleteMany({ where: { userId: existing.id } });
|
||
await prisma.album_members.deleteMany({ where: { userId: existing.id } });
|
||
await prisma.users.delete({ where: { id: existing.id } });
|
||
}
|
||
staffBId = (
|
||
await prisma.users.create({
|
||
data: {
|
||
account: STAFF_ACCOUNT,
|
||
passwordHash: await hashPassword(STAFF_PASSWORD),
|
||
displayName: '旅程协作者',
|
||
role: 'staff',
|
||
credentialStatus: 'active',
|
||
},
|
||
select: { id: true },
|
||
})
|
||
).id;
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
// 相册域直删(扁平字符串外键、无 DB 级联——子表在前;含 API 链路落库的
|
||
// upload_sessions/photo_jobs/outbox_events 行)。MinIO 对象随 dev 前缀
|
||
// 清理约定不在此删(同 helpers.ts)。
|
||
try {
|
||
if (journeyAlbumIds.length > 0) {
|
||
const photos = await prisma.photos.findMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
select: { id: true },
|
||
});
|
||
const photoIdList = photos.map((p) => p.id);
|
||
if (photoIdList.length > 0) {
|
||
await prisma.photo_variants.deleteMany({
|
||
where: { photoId: { in: photoIdList } },
|
||
});
|
||
await prisma.photo_jobs.deleteMany({
|
||
where: { photoId: { in: photoIdList } },
|
||
});
|
||
}
|
||
await prisma.photos.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.upload_sessions.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.outbox_events.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
const categories = await prisma.categories.findMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
select: { id: true },
|
||
});
|
||
if (categories.length > 0) {
|
||
await prisma.category_translations.deleteMany({
|
||
where: { categoryId: { in: categories.map((c) => c.id) } },
|
||
});
|
||
}
|
||
await prisma.categories.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.album_assets.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.album_members.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.album_translations.deleteMany({
|
||
where: { albumId: { in: journeyAlbumIds } },
|
||
});
|
||
await prisma.albums.deleteMany({ where: { id: { in: journeyAlbumIds } } });
|
||
}
|
||
await prisma.sessions.deleteMany({ where: { userId: staffBId } });
|
||
await prisma.album_members.deleteMany({ where: { userId: staffBId } });
|
||
await prisma.users.deleteMany({ where: { id: staffBId } });
|
||
} finally {
|
||
await prisma.$disconnect();
|
||
}
|
||
});
|
||
|
||
test('admin builds the album, staff publishes, viewers see it, admin hides', async ({ page }) => {
|
||
test.setTimeout(300_000);
|
||
const suffix = `${Date.now().toString(36)}${randomBytes(3).toString('hex')}`;
|
||
const zhTitle = `联调相册 ${suffix}`;
|
||
const enTitle = `Journey Album ${suffix}`;
|
||
const zhCategory = `主舞台 ${suffix}`;
|
||
const enCategory = `Main Stage ${suffix}`;
|
||
|
||
// ── ① admin UI:登录 → 建相册 A(zh+en)→ banner → 分类 → 邀 staffB → 登出 ──
|
||
await loginAdminUi(page);
|
||
await page.getByTestId('album-create-btn').click();
|
||
await expect(page.getByTestId('album-form-dialog')).toBeVisible();
|
||
await page.getByTestId('album-form-title-zh-CN').fill(zhTitle);
|
||
await page.getByRole('tab', { name: '英文' }).click();
|
||
await page.getByTestId('album-form-title-en').fill(enTitle);
|
||
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00');
|
||
await page.keyboard.press('Enter');
|
||
await page.getByTestId('album-form-submit').click();
|
||
await expect(page.getByTestId('album-form-dialog')).toBeHidden();
|
||
const row = page
|
||
.getByTestId('album-list-table')
|
||
.locator('tr', { hasText: zhTitle });
|
||
await expect(row).toBeVisible();
|
||
|
||
await row.getByTestId('album-detail-btn').click();
|
||
await expect(page.getByTestId('album-detail-card')).toBeVisible();
|
||
albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? '';
|
||
expect(albumId).not.toBe('');
|
||
journeyAlbumIds.push(albumId);
|
||
publicToken = (await page.getByTestId('album-detail-token').textContent()) ?? '';
|
||
expect(publicToken).toMatch(/^[0-9a-z]{20,32}$/);
|
||
|
||
// banner:真实 jpeg(sharp 生成)走 file chooser → init/PUT/complete → 预览。
|
||
const bannerJpeg = await makeJpeg(1200, 300, { r: 150, g: 100, b: 60 });
|
||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||
await page.getByTestId('album-banner-pick').click();
|
||
const fileChooser = await fileChooserPromise;
|
||
await fileChooser.setFiles({
|
||
name: 'banner-journey.jpg',
|
||
mimeType: 'image/jpeg',
|
||
buffer: bannerJpeg,
|
||
});
|
||
await expect(page.getByTestId('album-banner-status-done')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
await expect(page.getByTestId('album-banner-preview-img')).toBeVisible();
|
||
|
||
// 成员:账号前缀搜索 staffB(UI 走 lookupUsersByAccount)→ 邀请。
|
||
await page.getByTestId('album-members-tab').click();
|
||
const searchSelect = page.getByTestId('member-search-select');
|
||
await searchSelect.click();
|
||
await searchSelect.locator('input').fill(STAFF_ACCOUNT);
|
||
await page.getByTestId(`member-option-${STAFF_ACCOUNT}`).click();
|
||
await page.getByTestId('member-invite-btn').click();
|
||
await expect(
|
||
page.getByTestId('member-list-table').getByText(STAFF_ACCOUNT),
|
||
).toBeVisible();
|
||
|
||
// 分类:zh/en 名称。
|
||
await page.getByTestId('album-categories-tab').click();
|
||
await page.getByTestId('category-create-btn').click();
|
||
await page.getByTestId('category-form-name-zh').fill(zhCategory);
|
||
await page.getByTestId('category-form-name-en').fill(enCategory);
|
||
await page.getByTestId('category-form-submit').click();
|
||
await expect(page.getByTestId('category-form-dialog')).toBeHidden();
|
||
await expect(
|
||
page.getByTestId('category-list-table').getByText(zhCategory),
|
||
).toBeVisible();
|
||
categoryId = (
|
||
await prisma.categories.findFirstOrThrow({
|
||
where: { albumId },
|
||
orderBy: { sortOrder: 'asc' },
|
||
select: { id: true },
|
||
})
|
||
).id;
|
||
|
||
// 登出:头像 hover → 退出登录 → 确认 → 回登录页。
|
||
await page.getByAltText('avatar').hover();
|
||
await page.getByText('退出登录').click();
|
||
await page.getByRole('button', { name: '确定' }).click();
|
||
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 20_000 });
|
||
|
||
// ── ② admin API:登录 + 建空相册 B(空态冒烟用)──
|
||
const adminLogin = await apiFetch('/auth/password', {
|
||
method: 'POST',
|
||
body: { account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD },
|
||
});
|
||
expect(adminLogin.status).toBe(200);
|
||
adminToken = adminLogin.json.sessionToken as string;
|
||
const emptyAlbum = await apiFetch('/albums', {
|
||
method: 'POST',
|
||
token: adminToken,
|
||
body: {
|
||
translations: [
|
||
{ locale: 'zh-CN', title: `空相册 ${suffix}` },
|
||
{ locale: 'en', title: `Empty Album ${suffix}` },
|
||
],
|
||
defaultLocale: 'zh-CN',
|
||
startAt: '2026-10-01T10:00:00.000Z',
|
||
},
|
||
});
|
||
expect(emptyAlbum.status).toBe(201);
|
||
journeyAlbumIds.push(emptyAlbum.json.id as string);
|
||
emptyToken = emptyAlbum.json.publicToken as string;
|
||
|
||
// ── ② staffB API:登录 → init×3 → presign PUT 实写 → complete×3 ──
|
||
const staffLogin = await apiFetch('/auth/password', {
|
||
method: 'POST',
|
||
body: { account: STAFF_ACCOUNT, password: STAFF_PASSWORD },
|
||
});
|
||
expect(staffLogin.status).toBe(200);
|
||
staffBToken = staffLogin.json.sessionToken as string;
|
||
|
||
const shapes = [
|
||
{ w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 }, withCategory: true },
|
||
{ w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 }, withCategory: false },
|
||
{ w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 }, withCategory: false },
|
||
] as const;
|
||
const runSuffix = Date.now().toString(36);
|
||
for (let i = 0; i < shapes.length; i++) {
|
||
const shape = shapes[i]!;
|
||
const jpeg = await makeJpeg(shape.w, shape.h, shape.rgb);
|
||
const init = await apiFetch('/uploads', {
|
||
method: 'POST',
|
||
token: staffBToken,
|
||
body: {
|
||
albumId,
|
||
clientTaskId: `journey-${runSuffix}-${i}`,
|
||
filename: `journey-${i}.jpg`,
|
||
bytes: jpeg.length,
|
||
contentType: 'image/jpeg',
|
||
...(shape.withCategory ? { categoryId } : {}),
|
||
},
|
||
});
|
||
expect(init.status).toBe(201);
|
||
const credential = init.json.credential as {
|
||
url: string;
|
||
method: string;
|
||
headers: Record<string, string>;
|
||
};
|
||
const photoId = init.json.photoId as string;
|
||
photoIds.push(photoId);
|
||
const put = await fetch(credential.url, {
|
||
method: credential.method,
|
||
headers: credential.headers,
|
||
body: new Uint8Array(jpeg),
|
||
});
|
||
expect(put.ok).toBe(true);
|
||
const complete = await apiFetch(
|
||
`/uploads/${init.json.uploadId as string}/complete`,
|
||
{ method: 'POST', token: staffBToken },
|
||
);
|
||
expect(complete.status).toBe(200);
|
||
expect(complete.json).toEqual({
|
||
photoId,
|
||
processingState: 'uploaded',
|
||
});
|
||
}
|
||
expect(photoIds).toHaveLength(3);
|
||
|
||
// ── ② worker 发布事务 ×3(services/api full-chain 同款引导)→ verify ready ──
|
||
const publish = new PublishService(prisma, storage);
|
||
for (const photoId of photoIds) {
|
||
expect(await publish.processPhoto(photoId)).toBe('published');
|
||
}
|
||
for (const photoId of photoIds) {
|
||
const photo = await prisma.photos.findUniqueOrThrow({
|
||
where: { id: photoId },
|
||
});
|
||
expect(photo).toMatchObject({
|
||
processingState: 'ready',
|
||
visibility: 'published',
|
||
});
|
||
}
|
||
// 公开列表恰 3 张(API 侧 verify ready 的外部一致性)。
|
||
const list = await apiFetch(`/public/albums/${publicToken}/photos`);
|
||
expect(list.status).toBe(200);
|
||
const items = list.json.items as Array<{ photoId: string }>;
|
||
expect(items).toHaveLength(3);
|
||
expect(new Set(items.map((p) => p.photoId))).toEqual(new Set(photoIds));
|
||
|
||
// ── ③ viewer UI:zh 打开 → banner 渲染 → 切 en 内容随切 → 回 zh ──
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||
await page.goto(`/a/${publicToken}`);
|
||
const banner = page.locator('img.banner');
|
||
await expect(banner).toBeVisible();
|
||
await expect
|
||
.poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth))
|
||
.toBeGreaterThan(0);
|
||
await expect(page.locator('h1.title')).toHaveText(zhTitle);
|
||
await expect(page.locator('.chip')).toHaveCount(2); // 全部 + 主舞台
|
||
|
||
await page.locator('button.locale-switcher').click();
|
||
await expect(page.locator('h1.title')).toHaveText(enTitle);
|
||
await page.locator('button.locale-switcher').click(); // 切回 zh,后续断言用中文
|
||
await expect(page.locator('h1.title')).toHaveText(zhTitle);
|
||
|
||
// 瀑布流 3 张 → 分类 chip 过滤 1 张 → 全部恢复 3 张。
|
||
await expect(page.locator('.cell')).toHaveCount(3);
|
||
await page.getByRole('button', { name: zhCategory }).click();
|
||
await expect(page.locator('.cell')).toHaveCount(1);
|
||
await page.getByRole('button', { name: '全部' }).click();
|
||
await expect(page.locator('.cell')).toHaveCount(3);
|
||
|
||
// 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。
|
||
const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏)
|
||
await page.locator('.cell').nth(1).click();
|
||
await expect(page.locator('.lightbox')).toBeVisible();
|
||
await expect(
|
||
page.locator('img.photo'),
|
||
).toHaveAttribute('src', new RegExp(lightboxPhoto));
|
||
await expect
|
||
.poll(async () =>
|
||
page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth),
|
||
)
|
||
.toBeGreaterThan(0);
|
||
expect(page.url()).toContain(`/a/${publicToken}/p/${lightboxPhoto}`);
|
||
const box = await page.locator('.lightbox').boundingBox();
|
||
expect(box).not.toBeNull();
|
||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||
await page.mouse.wheel(0, -360);
|
||
await expect
|
||
.poll(async () =>
|
||
page
|
||
.locator('img.photo')
|
||
.evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
|
||
)
|
||
.toBeGreaterThan(1.2);
|
||
await page.locator('img.photo').dblclick();
|
||
await expect
|
||
.poll(async () =>
|
||
page
|
||
.locator('img.photo')
|
||
.evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
|
||
)
|
||
.toBeCloseTo(1, 1);
|
||
await page.locator('.control.close').click();
|
||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||
|
||
// 深链直达:/a/{token}/p/{photoId} 直接开在大图上。
|
||
await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`);
|
||
await expect(page.locator('.lightbox')).toBeVisible();
|
||
await expect(page.locator('img.photo')).toHaveAttribute(
|
||
'src',
|
||
new RegExp(lightboxPhoto),
|
||
);
|
||
|
||
// ── ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 800×500 ──
|
||
await loginAdminUi(page);
|
||
await page.goto(`${ADMIN_BASE}/#/albums/detail/${albumId}`);
|
||
await expect(page.getByTestId('album-detail-card')).toBeVisible();
|
||
await page.getByTestId('album-photos-tab').click();
|
||
const table = page.getByTestId('photo-list-table');
|
||
await expect(table).toBeVisible();
|
||
await expect(
|
||
table.locator('tr').filter({ has: page.getByTestId('photo-state-tag') }),
|
||
).toHaveCount(3);
|
||
const targetRow = table.locator('tr', { hasText: '800×500' });
|
||
await expect(targetRow.getByTestId('photo-state-tag')).toHaveText('就绪');
|
||
await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已发布');
|
||
await targetRow.getByTestId('photo-hide-btn').click();
|
||
await page.getByRole('button', { name: '确定' }).click();
|
||
await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已隐藏');
|
||
|
||
// ── ④ viewer:重载列表变 2 → 被隐深链 404 本地化提示 ──
|
||
await page.goto(`/a/${publicToken}`);
|
||
await expect(page.locator('.cell')).toHaveCount(2);
|
||
await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`);
|
||
const notice = page.locator('.deep-link-notice');
|
||
await expect(notice).toBeVisible();
|
||
await expect(notice).toContainText('照片不存在或已下线');
|
||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||
|
||
// ── ⑤ 空相册 B:viewer 空态提示 ──
|
||
await page.goto(`/a/${emptyToken}`);
|
||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||
await expect(page.locator('.cell')).toHaveCount(0);
|
||
});
|
||
});
|