import { expect, test, type Page } from "@playwright/test"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { ADMIN, EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "../helpers/auth"; import { openNavLink } from "../helpers/navigation"; import { TEST_IDS } from "../helpers/testIds"; type StepStatus = "PASS" | "FAIL"; type TrialStep = { role: "employee" | "manager" | "admin"; title: string; status: StepStatus; detail: string; screenshot: string; }; const rootDir = path.resolve(process.cwd(), ".."); const reportPath = path.join(rootDir, "artifacts/r70/auto-trialrun-report.md"); const issueCandidatesPath = path.join(rootDir, "artifacts/r70/auto-issue-candidates.csv"); const screenshotDir = path.join(rootDir, "deploy/trialrun/screenshots/r70-auto-trialrun"); function formatDate(offsetDays: number) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offsetDays); return value.toISOString().slice(0, 10); } function csvCell(value: string) { return `"${value.replaceAll('"', '""')}"`; } function safeName(value: string) { return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80); } async function selectFirstEnabledOption(page: Page, selector: string) { const locator = page.locator(selector); await expect .poll(async () => { const optionValues = await locator.locator("option").evaluateAll((options) => options .map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled })) .filter((option) => option.value && !option.disabled) .map((option) => option.value), ); return optionValues[0] ?? ""; }) .not.toBe(""); const [firstValue] = await locator.locator("option").evaluateAll((options) => options .map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled })) .filter((option) => option.value && !option.disabled) .map((option) => option.value), ); await locator.selectOption(firstValue); } async function createAndSubmitLeaveForManager(page: Page) { const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); const startDate = formatDate(260 + (Date.now() % 30)); try { const createResponsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(), ); await selectFirstEnabledOption(page, "#leave-type"); await page.locator("#leave-start-date").fill(startDate); await page.locator("#leave-end-date").fill(startDate); await page.locator("#leave-start-session").selectOption("am"); await page.locator("#leave-end-session").selectOption("pm"); await page.locator("#leave-reason").fill(`R70 自动辅助试运行 ${Date.now()}`); await page.getByRole("button", { name: "创建草稿" }).click(); const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; const leaveRequestId = createPayload.data?.id; if (!leaveRequestId) { throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`); } const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>( employeeClient.api, `/api/leave-requests/${leaveRequestId}/submit`, {}, ); return { startDate, leaveRequestId, currentTaskId: submitResult.currentTaskId }; } finally { await employeeClient.dispose(); } } function writeReports(steps: TrialStep[]) { mkdirSync(path.dirname(reportPath), { recursive: true }); const failures = steps.filter((item) => item.status === "FAIL"); const now = new Date().toISOString(); const lines = [ "# R70 自动辅助试运行报告", "", `生成时间:${now}`, "", "## 结论", "", failures.length ? `自动辅助试运行发现 ${failures.length} 个失败步骤,需人工确认后写入 R70 issue-log。` : "自动辅助试运行未发现阻断步骤。仍需人工判断流程是否顺手、文案是否清晰。", "", "## 步骤结果", "", "| Role | Step | Status | Screenshot | Detail |", "| --- | --- | --- | --- | --- |", ...steps.map((item) => `| ${item.role} | ${item.title} | ${item.status} | ${item.screenshot || "N/A"} | ${item.detail.replaceAll("|", "\\|")} |`, ), "", "## 说明", "", "- 截图输出在 `deploy/trialrun/screenshots/r70-auto-trialrun/`,该目录被 `.gitignore` 忽略。", "- 本报告只证明自动路径可执行,不替代真实用户对体验、文案和流程合理性的判断。", ]; writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8"); const issueHeader = [ "ID", "Role", "Severity", "Module", "Title", "Steps", "Expected", "Actual", "Screenshot", "Owner", "Disposition", "Status", "Notes", ]; const issueRows = failures.map((item, index) => [ `R70-AUTO-${String(index + 1).padStart(3, "0")}`, item.role, "P1", item.title, `自动辅助试运行失败:${item.title}`, item.title, "页面可打开且关键元素可见", item.detail, item.screenshot || "N/A", "TBD", "needs review", "OPEN", "由 R70 自动辅助试运行生成,需人工复核严重级别。", ].map(csvCell).join(","), ); writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8"); } test("R70 自动辅助试运行:员工、经理、管理员路径巡检", async ({ page }) => { test.setTimeout(240_000); mkdirSync(screenshotDir, { recursive: true }); const steps: TrialStep[] = []; let stepIndex = 0; let managerTaskId = ""; async function runStep(role: TrialStep["role"], title: string, action: () => Promise) { stepIndex += 1; const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${role}-${safeName(title)}.png`); const relativeScreenshot = path.relative(rootDir, screenshot); try { const detail = (await action()) ?? "ok"; await page.screenshot({ path: screenshot, fullPage: true }); steps.push({ role, title, status: "PASS", detail, screenshot: relativeScreenshot }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); steps.push({ role, title, status: "FAIL", detail, screenshot: relativeScreenshot }); } } try { await runStep("employee", "Portal 首页", async () => { await login(page, EMPLOYEE); await expect(page.getByRole("heading", { name: /今日待办聚合|今日工作台/ }).first()).toBeVisible(); }); await runStep("employee", "请假提交并生成经理待办", async () => { await (await openNavLink(page, "请假记录")).click(); await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); const leave = await createAndSubmitLeaveForManager(page); await page.goto("/leave-requests"); managerTaskId = leave.currentTaskId; await expect(page.getByRole("row").filter({ hasText: leave.startDate }).first()).toContainText("审批中", { timeout: 30_000 }); return `leaveRequestId=${leave.leaveRequestId}; taskId=${leave.currentTaskId}`; }); await runStep("employee", "报销入口", async () => { await (await openNavLink(page, "我的报销")).click(); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); }); await runStep("employee", "周报与待办入口", async () => { await (await openNavLink(page, "周报")).click(); await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible(); await page.goto("/todos"); await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible(); }); await runStep("employee", "公告与全局搜索", async () => { await (await openNavLink(page, "公告")).click(); await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); await page.getByTestId("global-search-trigger").click(); await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible(); }); await runStep("employee", "移动首页", async () => { try { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/mobile"); await expect(page.getByRole("heading", { name: "移动首页" })).toBeVisible(); } finally { await page.setViewportSize({ width: 1280, height: 720 }); } }); await runStep("manager", "待我处理与快捷审批入口", async () => { await logout(page, EMPLOYEE.name); await login(page, MANAGER); await (await openNavLink(page, "待我处理")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); if (managerTaskId) { await expect(page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: managerTaskId }).first()).toBeVisible(); } }); await runStep("manager", "团队状态与项目工作台", async () => { await (await openNavLink(page, "团队")).click(); await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); await (await openNavLink(page, "项目")).click(); await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible(); }); await runStep("admin", "管理入口与人员组织", async () => { await logout(page, MANAGER.name); await login(page, ADMIN); await (await openNavLink(page, /^管理$/)).click(); await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible(); await page.getByRole("link", { name: /人员/ }).first().click(); await expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible(); await page.goto("/organization"); await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible(); }); await runStep("admin", "管理、功能管理、审计日志", async () => { await (await openNavLink(page, /^管理$/)).click(); await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible(); await page.getByRole("link", { name: /功能管理/ }).first().click(); await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible(); await page.goto("/audit-logs"); await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible(); }); } finally { writeReports(steps); } const failures = steps.filter((item) => item.status === "FAIL"); expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); });