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, anchorName: room.anchorName, 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 sessionDetail = { session, timeline: { anchorAt: now, totalDurationSeconds: 5400, segments: Array.from({ length: 3 }, (_, index) => ({ recordTaskId: `task-${index + 1}`, segmentIndex: index + 1, status: 4, startedAt: new Date(Date.parse(now) + index * 1_800_000).toISOString(), endedAt: new Date(Date.parse(now) + (index + 1) * 1_800_000).toISOString(), offsetSeconds: index * 1800, durationSeconds: 1800, label: `/volume1/录制/示例主播/2026-08-02/分片-${String(index + 1).padStart(3, "0")}.mp4`, detail: "录制完成" })), events: [], heatBuckets: [] }, logs: [] }; 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 recordingFailure = { recordTaskId: "failed-task-1", recordSessionId: "failed-session-1", liveRoomId: room.id, liveRoomTitle: room.title, roomId: room.roomId, platformName: room.platformName, segmentIndex: 7, failureKind: "ReadableFragment", failureLabel: "异常退出分片", recommendedAction: "文件可以读取。确认内容有效后,将它转为待上传任务。", errorMessage: "FFmpeg 异常退出;原始文件会保留,不会被修复流程覆盖。", filePath: "/volume1/录制/示例主播/2026-08-02/一个用于验证窄屏换行的很长文件名-007.mp4", fileSizeBytes: 4_294_967_296, durationSeconds: 1789, fileExists: true, canAccept: true, canRepair: false, canRetryRoom: false, isRepairing: false, 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/${session.id}`) body = sessionDetail; 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, matchingRetryableCount: 0, queueHealth: { state: "Healthy", isPaused: false } }; else if (path === "/api/recovery/recording-failures") body = { items: [recordingFailure], totalCount: 1 }; else if (path === "/api/recovery") body = { storage: dashboard.storageStatus, liveRooms: [], finalizations: [] }; 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("login preserves the product hierarchy without viewport overflow", async ({ page }, testInfo) => { await page.addInitScript(() => localStorage.clear()); await page.goto("/login"); await expect(page.getByRole("heading", { name: /让每一次开播/ })).toBeVisible(); await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible(); await expect(page.getByRole("button", { name: /登录控制台/ })).toBeVisible(); await expectNoDocumentOverflow(page); await capture(page, testInfo, "login"); }); 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%"); if ((page.viewportSize()?.width ?? 0) <= 768) { const avatarGeometry = await page.locator(".app-user-btn").evaluate((button) => { const avatar = button.querySelector(".app-user-btn__avatar")!; const circle = avatar.querySelector("circle")!; const buttonRect = button.getBoundingClientRect(); const avatarRect = avatar.getBoundingClientRect(); const circleRect = circle.getBoundingClientRect(); return { tagName: avatar.tagName.toLowerCase(), buttonDelta: Math.abs(buttonRect.width - buttonRect.height), avatarDelta: Math.abs(avatarRect.width - avatarRect.height), circleDelta: Math.abs(circleRect.width - circleRect.height) }; }); expect(avatarGeometry.tagName).toBe("svg"); expect(avatarGeometry.buttonDelta).toBeLessThanOrEqual(1); expect(avatarGeometry.avatarDelta).toBeLessThanOrEqual(1); expect(avatarGeometry.circleDelta).toBeLessThanOrEqual(1); } await expectNoDocumentOverflow(page); await capture(page, testInfo, "dashboard"); }); test("mobile navigation animates route and active state without a tap frame", async ({ page }, testInfo) => { test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile navigation contract"); await page.emulateMedia({ reducedMotion: "no-preference" }); await page.goto("/"); await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible(); await expect(page.locator(".app-main")).toBeVisible(); await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1); await page.evaluate(() => { (window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = false; const observer = new MutationObserver((mutations) => { if (mutations.some((mutation) => mutation.target instanceof HTMLElement && mutation.target.className.includes("page-swap-") )) { (window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = true; observer.disconnect(); } }); observer.observe(document.querySelector(".app-main")!, { subtree: true, attributes: true, attributeFilter: ["class"] }); }); const recordNavigation = page.locator(".app-bottom-nav").getByRole("button", { name: "录制", exact: true }); const tapHighlight = await recordNavigation.evaluate((element) => getComputedStyle(element).webkitTapHighlightColor ); expect(tapHighlight).toBe("rgba(0, 0, 0, 0)"); await recordNavigation.click(); await expect(page).toHaveURL(/\/live-rooms$/); await expect(recordNavigation).toHaveClass(/is-active/); await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1); await expect.poll(() => page.evaluate(() => (window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved )).toBeTruthy(); const activeIndicator = await recordNavigation.locator(".app-bottom-nav__icon").evaluate((element) => ({ background: getComputedStyle(element).backgroundColor, duration: getComputedStyle(element).transitionDuration })); expect(activeIndicator.background).not.toBe("rgba(0, 0, 0, 0)"); expect(activeIndicator.duration).not.toBe("0s"); await capture(page, testInfo, "mobile-navigation-active"); }); test("dark mode teleported overlays inherit dark surfaces and borders", async ({ page }) => { await page.addInitScript(() => localStorage.setItem("live-recorder-ui-theme", "dark")); await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.locator(".app-user-btn").click(); const dropdown = page.locator(".el-dropdown__popper:visible").first(); await expect(dropdown).toBeVisible(); const colors = await dropdown.evaluate((element) => ({ background: getComputedStyle(element).backgroundColor, border: getComputedStyle(element).borderTopColor, rootBorder: getComputedStyle(document.documentElement).getPropertyValue("--el-border-color-light").trim() })); expect(colors.background).toBe("rgb(23, 34, 53)"); expect(colors.border).toBe("rgb(44, 60, 85)"); expect(colors.rootBorder).toBe("#2c3c55"); }); 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); const fixedActionCell = page.locator("td.el-table-fixed-column--right").first(); await expect(fixedActionCell).toBeVisible(); const fixedBackground = await fixedActionCell.evaluate((element) => window.getComputedStyle(element).backgroundColor ); expect(fixedBackground).not.toBe("transparent"); expect(fixedBackground).not.toMatch(/rgba\([^)]*,\s*0\s*\)$/); } await page.getByRole("button", { name: /^查看/ }).first().click(); const drawer = page.locator(".right-drawer"); const drawerFooter = page.locator(".right-drawer__footer"); await expect(drawerFooter).toBeVisible(); if ((page.viewportSize()?.width ?? 0) <= 768) { const viewportWidth = page.viewportSize()?.width ?? 0; await expect.poll(async () => { const box = await drawer.boundingBox(); return Boolean(box && box.x >= -1 && box.x + box.width <= viewportWidth + 1); }).toBe(true); if (viewportWidth <= 640) { const box = await drawer.boundingBox(); expect(Math.round(box?.width ?? 0)).toBe(viewportWidth); } const drawerBody = page.locator(".right-drawer__body"); const bodyOverflow = await drawerBody.evaluate((element) => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowY: getComputedStyle(element).overflowY })); expect(bodyOverflow.scrollWidth).toBeLessThanOrEqual(bodyOverflow.clientWidth); expect(bodyOverflow.overflowY).toBe("auto"); await drawerBody.evaluate((element) => { element.scrollTop = element.scrollHeight; }); const lastDetailRow = page.locator(".detail-panel__descriptions tr").last(); await expect(lastDetailRow).toBeVisible(); const lastRowBox = await lastDetailRow.boundingBox(); const fixedFooterBox = await drawerFooter.boundingBox(); expect(lastRowBox && fixedFooterBox && lastRowBox.y + lastRowBox.height) .toBeLessThanOrEqual((fixedFooterBox?.y ?? 0) + 1); } const footerBox = await drawerFooter.boundingBox(); expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1); await capture(page, testInfo, "live-room-drawer"); 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.getByText("主播 · 示例主播", { exact: true })).toBeVisible(); if ((page.viewportSize()?.width ?? 0) <= 768) { const tapHighlight = await page.getByRole("button", { name: "刷新列表" }).evaluate((element) => getComputedStyle(element).webkitTapHighlightColor ); expect(tapHighlight).toBe("rgba(0, 0, 0, 0)"); } 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 }, testInfo) => { 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(); if ((page.viewportSize()?.width ?? 0) <= 768) { await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2); const pageCount = Math.ceil(allSessions.length / expectedPageSize); const middlePage = Math.ceil(pageCount / 2); for (let pageNumber = 2; pageNumber < middlePage; pageNumber++) { await page.locator(".session-pagination__mobile .btn-next").click(); await expect(page.getByText(`分页录制会话 ${pageNumber * expectedPageSize + 1}`, { exact: true })).toBeVisible(); } await expect(page.locator(".session-pagination__position")).toContainText(`${middlePage}/ ${pageCount}`); await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2); await expectNoDocumentOverflow(page); await page.locator(".session-pagination__mobile").scrollIntoViewIfNeeded(); await capture(page, testInfo, "record-session-middle-page"); } }); 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("paused uploads expose retry controls without overflowing the viewport", async ({ page }, testInfo) => { await page.route("**/api/record-tasks/upload-status**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [{ ...uploadTask, uploadStatus: 5, uploadAttemptCount: 3, uploadErrorMessage: "OpenList 登录请求过多,队列已全局暂停,当前任务次数不会继续消耗。" }], totalCount: 1, notUploadedCount: 0, failedArtifactCount: 0, succeededCount: 0, failedCount: 0, queuedCount: 0, uploadingCount: 0, waitingRetryCount: 1, matchingRetryableCount: 1, queueHealth: { state: "RateLimited", isPaused: true, reason: "OpenList 登录请求触发限流。队列暂停期间不会发起新的上传请求,也不会消耗单任务重试额度。", retryAt: "2026-08-02T12:15:00Z" } }) }); }); await page.goto("/upload-tasks"); await expect(page.getByText("OpenList 正在限流", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "验证并恢复队列" })).toBeVisible(); await expect(page.getByRole("button", { name: "重试全部匹配" })).toBeVisible(); const retryLabel = (page.viewportSize()?.width ?? 0) < 1280 ? "立即重试" : "重试"; await expect(page.getByRole("button", { name: retryLabel, exact: true })).toBeVisible(); await expectNoDocumentOverflow(page); await capture(page, testInfo, "upload-queue-paused"); }); test("recording failure recovery adapts its actions and long paths to each viewport", async ({ page }, testInfo) => { await page.goto("/recovery"); await expect(page.getByRole("heading", { name: "恢复中心" })).toBeVisible(); await expect(page.getByRole("heading", { name: "录制失败产物" })).toBeVisible(); await expect(page.getByText(recordingFailure.filePath, { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "确认有效" })).toBeVisible(); if ((page.viewportSize()?.width ?? 0) <= 768) { await expect(page.locator(".failure-card")).toHaveCount(1); await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0); } else { await expect(page.locator(".failure-card")).toHaveCount(0); await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible(); } await expectNoDocumentOverflow(page); await capture(page, testInfo, "recording-failure-recovery"); }); test("mobile session details keep segment actions visible and left aligned", async ({ page }, testInfo) => { test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile session detail contract"); await page.goto("/record-tasks"); const sessionCard = page.locator(".session-card").first(); await expect(sessionCard).toBeVisible(); await sessionCard.locator(":scope > .data-card__actions").getByRole("button", { name: "查看", exact: true }).click(); await expect(page.getByRole("heading", { name: "会话详情" })).toBeVisible(); await expect(page.locator(".segment-card")).toHaveCount(3); await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0); const firstCard = page.locator(".segment-card").first(); const firstActions = firstCard.locator(".segment-card__actions"); const cardBox = await firstCard.boundingBox(); const actionsBox = await firstActions.boundingBox(); expect(cardBox).not.toBeNull(); expect(actionsBox).not.toBeNull(); expect(actionsBox!.x - cardBox!.x).toBeLessThanOrEqual(20); const lastActions = page.locator(".segment-card__actions").last(); await lastActions.scrollIntoViewIfNeeded(); await expect(lastActions.getByRole("button", { name: "查看分片" })).toBeVisible(); const actionsBottom = await lastActions.boundingBox(); const bottomNav = await page.locator(".app-bottom-nav").boundingBox(); expect(actionsBottom).not.toBeNull(); expect(bottomNav).not.toBeNull(); expect(actionsBottom!.y + actionsBottom!.height).toBeLessThanOrEqual(bottomNav!.y + 1); await capture(page, testInfo, "mobile-session-detail"); }); 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, matchingRetryableCount: 0, queueHealth: { state: "Healthy", isPaused: false } }) }); 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"); }); test("OpenList test distinguishes login success from a broken destination mount", async ({ page }) => { await page.route("**/api/settings", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ enableFileUpload: true, enableAutoUpload: true, uploadTarget: 3, openListUpload: { baseUrl: "https://openlist.example.com", username: "tester", password: "secret", basePath: "/archive", sourcePath: "/source", destinationPath: "/archive" } }) }); }); await page.route("**/api/settings/openlist/test", async (route) => { const request = route.request().postDataJSON(); expect(request.sourcePath).toBe("/source"); expect(request.destinationPath).toBe("/archive"); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: false, version: "4.1.10", message: "OpenList 登录成功,但一个或多个配置目录不可用。", sourcePath: { path: "/source", success: true, canWrite: false, message: "目录可访问。" }, destinationPath: { path: "/archive", success: false, canWrite: false, message: "provider timeout" } }) }); }); await page.goto("/settings/upload"); await expect(page.getByRole("heading", { name: "OpenList 服务端复制" })).toBeVisible(); await page.getByRole("button", { name: "测试连接" }).click(); await expect(page.getByText("配置异常", { exact: true })).toBeVisible(); await expect(page.getByText("目录可访问。", { exact: true })).toBeVisible(); await expect(page.getByText("provider timeout", { exact: true })).toBeVisible(); await expectNoDocumentOverflow(page); });