import { expect, test, type Page, type TestInfo } from "@playwright/test"; const now = "2026-08-02T12:00:00Z"; const room = { id: "room-1", platform: 0, platformName: "哔哩哔哩", sourceUrl: "https://live.example/123", originalLiveRoomUrl: "https://live.example/123", normalizedUrl: "https://live.example/123", roomId: "123456", title: "夏日音乐直播间", anchorName: "示例主播", isPinned: true, isPriority: false, overrides: {}, effectiveSettings: { preferredQuality: "origin", outputFormat: 0, saveMode: 1, recordingTemplate: 0, segmentDurationMinutes: 30, enableAutoReconnect: true, reconnectDelayMaxSeconds: 60, readWriteTimeoutMilliseconds: 30000, enableDanmakuRecording: true, danmakuIncludeNonChatEvents: false, danmakuMinPollIntervalMilliseconds: 1000, danmakuRetryDelayMaxSeconds: 30 }, isEnabled: true, availabilityStatus: 2, currentRecordingState: 2, lastAutoStartDecisionCode: "started", lastAutoStartDecisionSummary: "已自动开始录制", lastAutoStartDecisionDetail: "直播状态确认后创建录制会话。", lastAutoStartDecisionAt: now, lastCheckedAt: now, createdAt: now, updatedAt: now }; const task = { id: "task-1", liveRoomId: room.id, recordSessionId: "session-12345678", segmentIndex: 1, liveRoomTitle: room.title, platform: 0, roomId: room.roomId, status: 4, preferredQuality: "origin", outputFormat: 0, outputFilePath: "/volume1/录制/示例主播/2026-08-02/分片-001.mp4", createdAt: now, startedAt: now, endedAt: now, durationSeconds: 1800, uploadStatus: 0 }; const session = { id: "session-12345678", liveRoomId: room.id, liveRoomTitle: room.title, platform: 0, roomId: room.roomId, status: 4, preferredQuality: "origin", outputFormat: 0, saveMode: 1, activeSegmentIndex: 0, segmentCount: 1, createdAt: now, startedAt: now, endedAt: now, totalFileSizeBytes: 8_589_934_592, totalDanmakuMessageCount: 2680, uploadedSegmentCount: 0, failedUploadSegmentCount: 0, uploadingSegmentCount: 0, tasks: [task] }; const uploadTask = { recordTaskId: task.id, recordSessionId: session.id, liveRoomId: room.id, liveRoomTitle: room.title, platform: room.platform, roomId: room.roomId, segmentIndex: 1, outputFormat: 0, filePath: task.outputFilePath, fileSizeBytes: 8_589_934_592, uploadStatus: 0, uploadProgressPercent: 0, uploadAttemptCount: 0, createdAt: now }; const dashboard = { activeRecordingCount: 1, liveRoomCount: 3, offlineRoomCount: 5, totalRoomCount: 8, todayRecordingSeconds: 12600, todayDataBytes: 32_212_254_720, todayDanmakuCount: 12860, activeSessionCount: 1, recentErrorCount: 2, currentErrorCount: 0, storageStatus: { isEnabled: true, isAvailable: true, hasEnoughSpace: true, message: "空间充足", checkedPath: "/volume1/录制/示例主播/2026-08-02", totalBytes: 1_000_000_000_000, usedBytes: 750_000_000_000, availableBytes: 250_000_000_000, requiredBytes: 100_000_000_000, tier: "Green", usagePercent: 75, freePercent: 25, greenThresholdPercent: 30, redThresholdPercent: 10 }, recentSessions: [session], topRooms: [{ liveRoomId: room.id, title: room.title, anchorName: room.anchorName, platformName: room.platformName, roomId: room.roomId, sessionCount: 1, totalDurationSeconds: 12600 }], pendingTranscodeCount: 2, pendingUploadCount: 3, queuedDataBytes: 4_294_967_296 }; async function mockApi(page: Page) { await page.addInitScript(() => { localStorage.setItem("live-recorder-token", "e2e-token"); localStorage.setItem("live-recorder-user", JSON.stringify({ userId: "e2e-user", username: "tester", displayName: "界面测试" })); }); await page.route(/^http:\/\/127\.0\.0\.1:47173\/api\//, async (route) => { const url = new URL(route.request().url()); const path = url.pathname; let body: unknown = {}; if (path === "/api/dashboard") body = dashboard; else if (path === "/api/live-rooms") body = [room]; else if (path === "/api/record-sessions/page") body = { items: [session], totalCount: 1, skip: 0, take: 20, totalSessionCount: 1, activeSessionCount: 0, totalTaskCount: 1, totalDanmakuCount: session.totalDanmakuMessageCount }; else if (path === "/api/record-sessions") body = [session]; else if (path === "/api/record-tasks/upload-status") body = { items: [uploadTask], totalCount: 1, notUploadedCount: 1, failedArtifactCount: 0, succeededCount: 0, failedCount: 0, queuedCount: 0, uploadingCount: 0, waitingRetryCount: 0 }; else if (path === "/api/settings") body = {}; else if (path.includes("/record-sessions/stream")) { await route.fulfill({ status: 200, contentType: "text/event-stream", body: "" }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); }); } async function expectNoDocumentOverflow(page: Page) { await expect.poll(() => page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }))).toEqual(await page.evaluate(() => ({ scrollWidth: document.documentElement.clientWidth, clientWidth: document.documentElement.clientWidth }))); } async function capture(page: Page, testInfo: TestInfo, name: string) { await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true }); } test.beforeEach(async ({ page }) => { page.on("pageerror", (error) => console.error("[browser pageerror]", error.message)); page.on("console", (message) => { if (message.type() === "error") console.error("[browser console]", message.text()); }); await mockApi(page); }); test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "仪表盘" })).toBeVisible(); const storage = page.getByTestId("storage-capacity"); await expect(storage).toContainText("75.0%"); await expect(storage).toContainText("已使用"); await expect(storage).toContainText("25.0%"); await expectNoDocumentOverflow(page); await capture(page, testInfo, "dashboard"); }); test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => { await page.goto("/live-rooms"); await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible(); await expectNoDocumentOverflow(page); if ((page.viewportSize()?.width ?? 0) >= 768) { const actionHeader = page.getByRole("columnheader", { name: "操作" }).last(); await expect(actionHeader).toBeVisible(); const box = await actionHeader.boundingBox(); expect(box && box.x + box.width).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1); } await page.getByRole("button", { name: /^查看/ }).first().click(); const drawerFooter = page.locator(".right-drawer__footer"); await expect(drawerFooter).toBeVisible(); const footerBox = await drawerFooter.boundingBox(); expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1); await page.getByRole("button", { name: "关闭", exact: true }).click(); await expect(drawerFooter).toBeHidden(); await page.getByRole("button", { name: "新增直播间" }).click(); const dialogFooter = page.locator(".el-dialog__footer"); await expect(dialogFooter).toBeVisible(); const dialogFooterBox = await dialogFooter.boundingBox(); expect(dialogFooterBox && dialogFooterBox.y + dialogFooterBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1); await page.getByRole("button", { name: "取消", exact: true }).click(); await expect(dialogFooter).toBeHidden(); await capture(page, testInfo, "live-rooms"); }); test("mobile live room list paginates large collections", async ({ page }) => { test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract"); const manyRooms = Array.from({ length: 25 }, (_, index) => ({ ...room, id: `room-${index + 1}`, roomId: String(100000 + index + 1), title: `分页直播间 ${index + 1}`, anchorName: `分页主播 ${index + 1}` })); await page.route("**/api/live-rooms", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manyRooms) }); }); await page.goto("/live-rooms"); await expect(page.locator(".room-card")).toHaveCount(12); await expect(page.getByText("分页直播间 1", { exact: true })).toBeVisible(); await page.locator(".mobile-room-pagination .btn-next").click(); await expect(page.getByText("分页直播间 13", { exact: true })).toBeVisible(); await expect(page.locator(".room-card")).toHaveCount(12); }); test("record task cards and tables expose one primary action", async ({ page }, testInfo) => { await page.goto("/record-tasks"); await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible(); await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible(); await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible(); await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible(); await expectNoDocumentOverflow(page); await capture(page, testInfo, "record-tasks"); }); test("record sessions paginate on the server and bound the rendered page", async ({ page }) => { const allSessions = Array.from({ length: 200 }, (_, index) => ({ ...session, id: `session-${index + 1}`, liveRoomTitle: `分页录制会话 ${index + 1}`, roomId: String(200000 + index + 1), tasks: [{ ...task, id: `task-${index + 1}`, recordSessionId: `session-${index + 1}` }] })); const requests: Array<{ skip: number; take: number }> = []; await page.route("**/api/record-sessions/page**", async (route) => { const url = new URL(route.request().url()); const skip = Number(url.searchParams.get("skip") || 0); const take = Number(url.searchParams.get("take") || 20); requests.push({ skip, take }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: allSessions.slice(skip, skip + take), totalCount: allSessions.length, skip, take, totalSessionCount: allSessions.length, activeSessionCount: 0, totalTaskCount: allSessions.length, totalDanmakuCount: allSessions.length * session.totalDanmakuMessageCount }) }); }); await page.goto("/record-tasks"); const expectedPageSize = (page.viewportSize()?.width ?? 0) < 768 ? 12 : 20; await expect.poll(() => requests.length).toBeGreaterThan(0); expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize }); await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible(); await expect(page.locator((page.viewportSize()?.width ?? 0) < 768 ? ".session-card" : ".session-panel")) .toHaveCount(expectedPageSize); const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length); expect(renderedNodeCount).toBeLessThan(5_000); await page.locator(".session-pagination .btn-next").click(); await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy(); await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible(); }); test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => { test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract"); await page.addInitScript(() => { class MockEventSource { onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; private listeners = new Map void>>(); constructor() { (window as any).__recordSessionEventSource = this; window.setTimeout(() => this.onopen?.(new Event("open")), 0); } addEventListener(type: string, listener: EventListenerOrEventListenerObject) { const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event); this.listeners.set(type, [...(this.listeners.get(type) ?? []), callback]); } emit(type: string) { this.listeners.get(type)?.forEach((listener) => listener(new MessageEvent(type, { data: "{}" }))); } close() {} } Object.defineProperty(window, "EventSource", { configurable: true, value: MockEventSource }); (window as any).__emitRecordSessionRefresh = () => (window as any).__recordSessionEventSource?.emit("refresh"); }); const allSessions = Array.from({ length: 60 }, (_, index) => ({ ...session, id: `refresh-session-${index + 1}`, liveRoomTitle: `刷新录制会话 ${index + 1}`, tasks: [{ ...task, id: `refresh-task-${index + 1}`, recordSessionId: `refresh-session-${index + 1}` }] })); let requestCount = 0; await page.route("**/api/record-sessions/page**", async (route) => { requestCount++; const url = new URL(route.request().url()); const skip = Number(url.searchParams.get("skip") || 0); const take = Number(url.searchParams.get("take") || 12); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: allSessions.slice(skip, skip + take), totalCount: allSessions.length, skip, take, totalSessionCount: allSessions.length, activeSessionCount: 0, totalTaskCount: allSessions.length, totalDanmakuCount: 0 }) }); }); await page.goto("/record-tasks"); await expect(page.locator(".session-card")).toHaveCount(12); const main = page.locator(".app-main"); await main.evaluate((element) => { element.scrollTop = element.scrollHeight; }); const scrollTopBefore = await main.evaluate((element) => element.scrollTop); expect(scrollTopBefore).toBeGreaterThan(500); await page.evaluate(() => (window as any).__emitRecordSessionRefresh()); await expect.poll(() => requestCount).toBeGreaterThan(1); await expect(page.locator(".session-card")).toHaveCount(12); const scrollTopAfter = await main.evaluate((element) => element.scrollTop); expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2); }); test("upload tasks use cards on mobile and never require table horizontal scrolling", async ({ page }, testInfo) => { await page.goto("/upload-tasks"); await expect(page.getByRole("heading", { name: "上传任务" })).toBeVisible(); await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible(); await expect(page.getByText(task.outputFilePath, { exact: true })).toBeVisible(); await expectNoDocumentOverflow(page); if ((page.viewportSize()?.width ?? 0) < 1280) { await expect(page.locator(".upload-item-card")).toBeVisible(); await expect(page.locator(".upload-table")).toHaveCount(0); await expect(page.getByRole("button", { name: "查看详情" })).toBeVisible(); await expect(page.getByRole("button", { name: "立即上传" })).toBeVisible(); } else { await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible(); const tableScroll = page.locator(".upload-table .el-scrollbar__wrap"); await expect(tableScroll).toBeVisible(); const dimensions = await tableScroll.evaluate((element) => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth })); expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); const uploadButton = page.getByRole("button", { name: "上传", exact: true }); await expect(uploadButton).toBeVisible(); const uploadButtonBox = await uploadButton.boundingBox(); expect(uploadButtonBox && uploadButtonBox.x + uploadButtonBox.width) .toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1); } await capture(page, testInfo, "upload-tasks"); }); test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => { test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract"); const allItems = Array.from({ length: 60 }, (_, index) => ({ ...uploadTask, recordTaskId: `upload-task-${index + 1}`, segmentIndex: index + 1, liveRoomTitle: `轮询直播间 ${index + 1}`, uploadStatus: 3, uploadProgressPercent: 10 })); let requestCount = 0; let inFlight = 0; let maxInFlight = 0; await page.route("**/api/record-tasks/upload-status**", async (route) => { requestCount++; inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); const url = new URL(route.request().url()); const skip = Number(url.searchParams.get("skip") || 0); const take = Number(url.searchParams.get("take") || 50); if (requestCount > 1) { await new Promise((resolve) => setTimeout(resolve, 500)); } const progress = requestCount > 1 ? 42 : 10; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: allItems.slice(skip, skip + take).map((item) => ({ ...item, uploadProgressPercent: progress })), totalCount: allItems.length, notUploadedCount: 0, failedArtifactCount: 0, succeededCount: 0, failedCount: 0, queuedCount: 0, uploadingCount: allItems.length, waitingRetryCount: 0 }) }); inFlight--; }); await page.goto("/upload-tasks"); await expect(page.locator(".upload-item-card")).toHaveCount(12); await expect(page.getByText("10.0% · 第 0 次").first()).toBeVisible(); const main = page.locator(".app-main"); await main.evaluate((element) => { element.scrollTop = element.scrollHeight; }); const scrollTopBefore = await main.evaluate((element) => element.scrollTop); expect(scrollTopBefore).toBeGreaterThan(500); await expect.poll(() => requestCount, { timeout: 10_000 }).toBeGreaterThan(1); await expect(page.locator(".upload-item-card")).toHaveCount(12); await expect(page.locator(".el-skeleton")).toHaveCount(0); await expect(page.getByText("42.0% · 第 0 次").first()).toBeVisible(); const scrollTopAfter = await main.evaluate((element) => element.scrollTop); expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2); expect(maxInFlight).toBe(1); }); test("settings layer advanced controls without clipping the quick bar", async ({ page }, testInfo) => { await page.goto("/settings/recording"); await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible(); await expect(page.getByRole("heading", { name: "保留清理" })).toBeHidden(); await page.getByText("高级设置", { exact: true }).click(); await expect(page.getByRole("heading", { name: "保留清理" })).toBeVisible(); await expectNoDocumentOverflow(page); await capture(page, testInfo, "settings"); });