Files

850 lines
34 KiB
TypeScript

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<SVGSVGElement>(".app-user-btn__avatar")!;
const circle = avatar.querySelector<SVGCircleElement>("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<string, Array<(event: Event) => 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);
});