import { expect, type APIRequestContext, type Browser, type Locator, type Page } from "@playwright/test"; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { test } from "../helpers/fixtures"; import { apiGetData, apiPostData } from "../helpers/auth"; import { createLiveApiClient, createLiveBrowserStorageState, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth"; import { openNavLink } from "../helpers/navigation"; import { TEST_IDS } from "../helpers/testIds"; import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workflow"; type StepStatus = "PASS" | "FAIL" | "SKIP"; type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动" | "企业微信UA"; type FlowKey = "preflight" | "reimbursement-finance" | "leave" | "purchase-attachment" | "mobile" | "wecom-ua" | "all"; type TrialStep = { flow: FlowName; role: string; title: string; status: StepStatus; businessId: string; pcResult: string; mobileResult: string; issueId: string; conclusion: string; detail: string; screenshot: string; }; type StepResult = string | Partial>; type ReimbursementDetail = { id: string; formNumber: string | null; status: string; totalAmount: string | null; payment?: { status?: string | null; paymentVoucherNo?: string | null; } | null; }; type PaymentBatch = { id: string; name: string; status: string; reviewRequirement: { completedReviewCount: number; requiredReviewCount: number; nextStep: string; }; }; type TrialState = { reimbursementId?: string; reimbursementCode?: string; reimbursementTaskId?: string; reimbursementInvoiceId?: string; reimbursementSubmitted?: boolean; leaveRequestId?: string; leaveTaskId?: string; leaveDate?: string; purchaseRequestId?: string; purchaseRequestNo?: string; purchaseTaskId?: string; paymentBatchId?: string; paymentBatchName?: string; }; const rootDir = path.resolve(process.cwd(), ".."); const reportDir = path.join(rootDir, "artifacts/r7e/browser-business-trialrun"); const reportPath = path.join(reportDir, "report.md"); const issueCandidatesPath = path.join(reportDir, "issues.csv"); const screenshotDir = path.join(rootDir, "output/playwright/r7e-browser-business-trialrun"); const validFlows = new Set(["preflight", "reimbursement-finance", "leave", "purchase-attachment", "mobile", "wecom-ua", "all"]); const loginRateLimitRetryMs = Number.parseInt(process.env.R7E_LOGIN_RETRY_WAIT_MS ?? "65000", 10); const wecomUa = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; function nowStamp() { return String(Date.now()); } function formatDate(offsetDays: number) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offsetDays); return value.toISOString().slice(0, 10); } function uniqueFutureOffset(minOffsetDays: number) { return minOffsetDays + (Math.floor(Date.now() / 1000) % 2000); } function futureWeekday(minOffsetDays: number) { for (let offset = minOffsetDays; offset < minOffsetDays + 14; offset += 1) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offset); const weekday = value.getUTCDay(); if (weekday >= 1 && weekday <= 5) { return value.toISOString().slice(0, 10); } } return formatDate(minOffsetDays); } function safeName(value: string) { return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80); } function csvCell(value: string) { return `"${value.replaceAll('"', '""')}"`; } function tableCell(value: string) { return value.replaceAll("|", "\\|").replaceAll("\n", " "); } function stripAnsi(value: string) { return value.replace(/\u001b\[[0-9;]*m/g, ""); } function resolveSelectedFlow(): FlowKey { const rawFlow = (process.env.R7E_BROWSER_FLOW?.trim() || "all") as FlowKey; if (!validFlows.has(rawFlow)) { throw new Error(`不支持的 R7E_BROWSER_FLOW:${rawFlow}`); } return rawFlow; } function liveUrl(pathname: string) { return new URL(pathname, process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com").toString(); } function shouldRunFlow(selectedFlow: FlowKey, flow: FlowKey) { return selectedFlow === "all" || selectedFlow === flow; } function looksLikeLoginRateLimit(error: unknown) { const message = stripAnsi(error instanceof Error ? error.message : String(error)); return /请求过于频繁|rate.?limit|too many requests|HTTP_429|429/i.test(message); } async function waitForLoginRateLimitWindow() { if (Number.isFinite(loginRateLimitRetryMs) && loginRateLimitRetryMs > 0) { await new Promise((resolve) => setTimeout(resolve, loginRateLimitRetryMs)); } } function resolveLivePdfSample() { const candidate = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() || process.env.LIVE_OCR_INVOICE_FILE?.trim() || ""; if (!candidate || !existsSync(candidate)) { return ""; } try { return statSync(candidate).isFile() && path.extname(candidate).toLowerCase() === ".pdf" ? candidate : ""; } catch { return ""; } } async function selectFirstEnabledOption(locator: Locator) { 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 firstEnabledExpenseTypeId(api: APIRequestContext) { const response = await api.get("/api/expense-types?enabled=true"); const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } }; const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; if (!response.ok() || !item?.id) { throw new Error(`无法读取费用类型:HTTP ${response.status()}`); } return item.id; } async function createReimbursementItem(api: APIRequestContext, reimbursementId: string, marker: string) { const expenseTypeId = await firstEnabledExpenseTypeId(api); const response = await api.post(`/api/reimbursements/${reimbursementId}/items`, { data: { expenseTypeId, expenseDate: new Date().toISOString().slice(0, 10), amount: "168.30", description: `R7E 浏览器报销 ${marker}`, }, }); if (!response.ok()) { throw new Error(`创建报销明细失败:HTTP ${response.status()} ${await response.text()}`); } } async function uploadLiveInvoicePdf(page: Page, fileName: string, buffer: Buffer) { await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click(); await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toBeVisible(); const uploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload"; }); await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({ name: fileName, mimeType: "application/pdf", buffer, }); await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded(); await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click(); const uploadResponse = await uploadResponsePromise; const uploadPayload = (await uploadResponse.json()) as { data?: { invoicePoolId?: string | null; status?: string | null; uploadAction?: string | null; unavailableReason?: string | null; unavailableMessage?: string | null; }; }; const invoiceId = uploadPayload.data?.invoicePoolId ?? null; if (!uploadResponse.ok()) { throw new Error(`发票上传失败:HTTP ${uploadResponse.status()} ${JSON.stringify(uploadPayload)}`); } if (!invoiceId) { await page.keyboard.press("Escape").catch(() => undefined); await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0).catch(() => undefined); return { invoiceId: null, status: uploadPayload.data?.status ?? "unknown", uploadAction: uploadPayload.data?.uploadAction ?? "unknown", reason: uploadPayload.data?.unavailableReason ?? "missing_invoice_id", message: uploadPayload.data?.unavailableMessage ?? "上传未返回可绑定票据 ID", }; } await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).toContainText(fileName, { timeout: 90_000 }); await page.keyboard.press("Escape"); await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0); return { invoiceId, status: "available", uploadAction: "created", reason: "", message: "" }; } async function createLeaveDraft(page: Page, startDate: string, reason: string) { const createResponsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(), ); await selectFirstEnabledOption(page.locator("#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(reason); await expect(page.getByRole("button", { name: "创建草稿" })).toBeEnabled({ timeout: 10_000 }); 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)}`); } return leaveRequestId; } async function checkNoHorizontalOverflow(page: Page) { const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow, `页面横向溢出 ${overflow}px: ${page.url()}`).toBeLessThanOrEqual(2); } async function expectAnyVisible(page: Page, testIds: string[]) { await expect .poll(async () => { for (const testId of testIds) { if (await page.getByTestId(testId).first().isVisible().catch(() => false)) { return testId; } } return ""; }) .not.toBe(""); } async function loginRole(page: Page, role: LiveSmokeRole) { async function runLoginAttempt() { await page.context().clearCookies(); await page.goto(liveUrl("/login")); await page.evaluate(() => { window.localStorage.clear(); window.sessionStorage.clear(); }); return loginLiveBrowserUser(page, role); } try { return await runLoginAttempt(); } catch (error) { if (!looksLikeLoginRateLimit(error)) { throw error; } await waitForLoginRateLimitWindow(); return runLoginAttempt(); } } async function createLiveApiClientWithRetry(role: LiveSmokeRole) { try { return await createLiveApiClient(role); } catch (error) { if (!looksLikeLoginRateLimit(error)) { throw error; } await waitForLoginRateLimitWindow(); return createLiveApiClient(role); } } async function createLiveBrowserStorageStateWithRetry(role: LiveSmokeRole) { try { return await createLiveBrowserStorageState(role); } catch (error) { if (!looksLikeLoginRateLimit(error)) { throw error; } await waitForLoginRateLimitWindow(); return createLiveBrowserStorageState(role); } } async function createMobileRolePage(browser: Browser, role: LiveSmokeRole) { const storageState = await createLiveBrowserStorageStateWithRetry(role); const context = await browser.newContext({ baseURL: process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com", storageState, viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: "zh-CN", serviceWorkers: "block", }); const mobilePage = await context.newPage(); return { context, page: mobilePage }; } function writeReports(steps: TrialStep[]) { mkdirSync(reportDir, { recursive: true }); const failures = steps.filter((item) => item.status === "FAIL"); const skips = steps.filter((item) => item.status === "SKIP"); const generatedAt = new Date().toISOString(); const flowName = process.env.R7E_BROWSER_FLOW?.trim() || "all"; const flowReportPath = path.join(reportDir, `report-${safeName(flowName)}.md`); const flowIssueCandidatesPath = path.join(reportDir, `issues-${safeName(flowName)}.csv`); const lines = [ "# R7E 浏览器业务先行试运行报告", "", `生成时间:${generatedAt}`, `执行 flow:${flowName}`, "", "## 结论", "", failures.length ? `发现 ${failures.length} 个失败步骤,需分流到 R7E/R7E-16 或 QAOPS8。` : skips.length ? `未发现失败步骤,但有 ${skips.length} 个 SKIP 步骤,不能据此关闭对应人工验收。` : "公网浏览器业务线自动辅助试运行未发现阻断步骤。", "", "## 固定口径", "", "- 本报告只覆盖公网 PC/移动浏览器业务线和企业微信 UA 模拟补测。", "- 企业微信免登录、企业微信消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍需单独验收。", "- 报告不记录密码、token、票据内容或私人联系方式。", "", "## 步骤结果", "", "| 流程 | 账号角色 | 测试单据 | PC 结果 | 移动浏览器结果 | 问题编号 | 结论 |", "| --- | --- | --- | --- | --- | --- | --- |", ...steps.map((item) => `| ${tableCell(item.flow)} | ${tableCell(item.role)} | ${tableCell(item.businessId)} | ${tableCell(item.pcResult)} | ${tableCell(item.mobileResult)} | ${tableCell(item.issueId)} | ${tableCell(item.conclusion)} |`, ), "", "## 截图和详情", "", "| 流程 | 步骤 | 状态 | 截图 | 详情 |", "| --- | --- | --- | --- | --- |", ...steps.map((item) => `| ${tableCell(item.flow)} | ${tableCell(item.title)} | ${item.status} | ${tableCell(item.screenshot || "N/A")} | ${tableCell(item.detail)} |`, ), ]; const reportContent = `${lines.join("\n")}\n`; writeFileSync(reportPath, reportContent, "utf-8"); writeFileSync(flowReportPath, reportContent, "utf-8"); const issueHeader = ["ID", "Flow", "Severity", "Title", "Actual", "Screenshot", "Disposition", "Status", "Notes"]; const issueRows = failures.map((item, index) => [ `R7E-BROWSER-${String(index + 1).padStart(3, "0")}`, item.flow, item.flow === "公共前置" ? "BLOCKER" : "HIGH", item.title, item.detail, item.screenshot || "N/A", item.flow === "移动" || item.flow === "企业微信UA" ? "QAOPS8/UX8/R7E" : "R7E/WF8/BIZ8", "OPEN", "由 R7E 浏览器业务先行试运行生成,需人工复核严重级别。", ].map(csvCell).join(","), ); const issueContent = `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`; writeFileSync(issueCandidatesPath, issueContent, "utf-8"); writeFileSync(flowIssueCandidatesPath, issueContent, "utf-8"); } test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器", async ({ page, browser, reimbursementDetail, workflowDetail }) => { test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); test.skip(process.env.E2E_MODE !== "live", "仅在 live 模式下执行"); test.skip(process.env.R7E_BROWSER_TRIALRUN !== "1", "仅在 R7E 浏览器业务试运行模式下执行"); test.setTimeout(900_000); mkdirSync(screenshotDir, { recursive: true }); const steps: TrialStep[] = []; const state: TrialState = {}; const marker = nowStamp(); const selectedFlow = resolveSelectedFlow(); let stepIndex = 0; async function runStep(flow: FlowName, role: string, title: string, action: () => Promise) { stepIndex += 1; const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${safeName(flow)}-${safeName(title)}.png`); const relativeScreenshot = path.relative(rootDir, screenshot); try { const rawResult = await action(); const result = typeof rawResult === "string" || !rawResult ? { detail: rawResult || "ok" } : rawResult; if (!result.screenshot) { await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); } steps.push({ flow, role, title, status: result.status ?? "PASS", businessId: result.businessId ?? "-", pcResult: result.pcResult ?? "PASS", mobileResult: result.mobileResult ?? "-", issueId: result.issueId ?? "-", conclusion: result.conclusion ?? "通过", detail: result.detail ?? "ok", screenshot: result.screenshot ?? relativeScreenshot, }); } catch (error) { const detail = stripAnsi(error instanceof Error ? error.message : String(error)); await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); steps.push({ flow, role, title, status: "FAIL", businessId: "-", pcResult: `FAIL: ${detail.slice(0, 180)}`, mobileResult: "-", issueId: "待分流", conclusion: "失败", detail: `${detail}; url=${page.url()}`, screenshot: relativeScreenshot, }); } } async function approveTaskViaBrowser(role: LiveSmokeRole, taskId: string, expectedText: RegExp | string) { const user = await loginRole(page, role); try { await (await openNavLink(page, "待我处理")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); await page.goto(`/workflow/tasks/${taskId}`); await workflowDetail.assertLoaded(); await expect(workflowDetail.summary).toContainText(expectedText); const approveResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/workflow/tasks/${taskId}/approve`; }); await workflowDetail.approve(); const approveResponse = await approveResponsePromise; if (!approveResponse.ok()) { throw new Error(`审批通过请求失败:HTTP ${approveResponse.status()} ${await approveResponse.text()}`); } } finally { await logoutLiveUser(page, { name: user.name }).catch(() => undefined); } } try { await runStep("公共前置", "anonymous", "公网 ready 探活", async () => { const ready = await page.request.get("/ready"); expect(ready.ok()).toBeTruthy(); return `ready status=${ready.status()}`; }); if (shouldRunFlow(selectedFlow, "preflight")) { for (const role of ["employee", "manager", "finance", "financeMgr", "admin"] as const) { await runStep("公共前置", role, `${role} 登录 Portal`, async () => { const user = await loginRole(page, role); await expect(page).toHaveURL(/\/portal/); await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible(); await logoutLiveUser(page, { name: user.name }); return `login ok: ${role}`; }); } } if (steps.some((item) => item.flow === "公共前置" && item.status !== "PASS")) { return; } if (selectedFlow === "preflight") { return; } if (shouldRunFlow(selectedFlow, "reimbursement-finance")) { const existingReimbursementId = process.env.R7E_EXISTING_REIMBURSEMENT_ID?.trim() || ""; if (existingReimbursementId) { await runStep("报销", "employee/finance", "复用已创建报销单进入财务验证", async () => { const employeeApi = await createLiveApiClientWithRetry("employee"); try { const detail = await apiGetData(employeeApi.api, `/api/reimbursements/${existingReimbursementId}`); state.reimbursementId = existingReimbursementId; state.reimbursementCode = detail.formNumber ?? existingReimbursementId; state.reimbursementSubmitted = true; return { businessId: `${state.reimbursementCode}; status=${detail.status}`, pcResult: "PASS: 复用本轮已创建并提交的报销单进入财务验证", detail: `existing reimbursementId=${existingReimbursementId}; status=${detail.status}`, }; } finally { await employeeApi.dispose(); } }); } else { await runStep("报销", "employee", "创建报销、上传授权 PDF 并提交", async () => { const pdfSample = resolveLivePdfSample(); if (!pdfSample) { return { status: "SKIP", pcResult: "SKIP: 缺少授权 PDF 样票", conclusion: "暂停", detail: "PLAYWRIGHT_OCR_LIVE_FILE 或 LIVE_OCR_INVOICE_FILE 未指向可用 PDF;未创建报销提交链路。", }; } const employeeApi = await createLiveApiClientWithRetry("employee"); const user = await loginRole(page, "employee"); try { await (await openNavLink(page, "我的报销")).click(); await expect(page).toHaveURL(/\/reimbursements/); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); const createResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok(); }); await page.getByTestId("reimbursements-create").click(); await page.getByRole("menuitem", { name: "空白报销" }).click(); const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; const reimbursementId = createPayload.data?.id; if (!reimbursementId) { throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`); } state.reimbursementId = reimbursementId; await createReimbursementItem(employeeApi.api, reimbursementId, marker); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); const uploadResult = await uploadLiveInvoicePdf(page, `r7e-${marker}.pdf`, readFileSync(pdfSample)); if (!uploadResult.invoiceId) { return { status: "SKIP", businessId: `reimbursement=${reimbursementId}; invoice=${uploadResult.status}`, pcResult: `SKIP: 授权 PDF 样票不可绑定,${uploadResult.reason}`, conclusion: "暂停", detail: `reimbursementId=${reimbursementId}; uploadAction=${uploadResult.uploadAction}; reason=${uploadResult.reason}; message=${uploadResult.message}`, }; } const invoiceId = uploadResult.invoiceId; state.reimbursementInvoiceId = invoiceId; const submitResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/reimbursements/${reimbursementId}/submit`; }); await reimbursementDetail.submitReimbursement(); const submitResponse = await submitResponsePromise; if (!submitResponse.ok()) { throw new Error(`报销提交失败:HTTP ${submitResponse.status()} ${await submitResponse.text()}`); } const submitPayload = (await submitResponse.json()) as { data?: { currentTaskId?: string } }; state.reimbursementTaskId = submitPayload.data?.currentTaskId; state.reimbursementSubmitted = true; await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 }); await expect(reimbursementDetail.workflowProgress).toContainText("当前节点", { timeout: 30_000 }); const detail = await apiGetData(employeeApi.api, `/api/reimbursements/${reimbursementId}`); state.reimbursementCode = detail.formNumber ?? reimbursementId; return { businessId: `${state.reimbursementCode}; invoice=${invoiceId}; task=${state.reimbursementTaskId ?? "-"}`, pcResult: "PASS: 报销创建、明细、PDF 上传、提交、审批进度可见", detail: `reimbursementId=${reimbursementId}; formNumber=${state.reimbursementCode}; invoiceId=${invoiceId}`, }; } finally { await employeeApi.dispose(); await logoutLiveUser(page, { name: user.name }).catch(() => undefined); } }); await runStep("审批", "manager", "审批人处理报销待办并员工回看历史", async () => { if (!state.reimbursementId) { return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少报销单", detail: "报销创建未完成,跳过审批联动。" }; } if (!state.reimbursementSubmitted || !state.reimbursementTaskId) { return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 报销未提交", detail: "报销授权 PDF 样票不可绑定,未产生报销审批待办。" }; } let managerApi: Awaited> | null = null; try { let taskId = state.reimbursementTaskId; if (!taskId) { managerApi = await createLiveApiClientWithRetry("manager"); taskId = (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id; } await approveTaskViaBrowser("manager", taskId, /对象类型:报销|reimbursement|报销/); const employee = await loginRole(page, "employee"); await page.goto(`/reimbursements/${state.reimbursementId}`); await reimbursementDetail.assertLoaded(); await expect(page.getByTestId("reimbursement-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 }); await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); return { businessId: `${state.reimbursementCode ?? state.reimbursementId}; task=${taskId}`, pcResult: "PASS: 审批人处理待办,员工可回看审批历史", detail: `approved reimbursement task=${taskId}`, }; } finally { await managerApi?.dispose(); } }); } } if (shouldRunFlow(selectedFlow, "leave")) { await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => { const employeeApi = await createLiveApiClientWithRetry("employee"); const startDate = futureWeekday(uniqueFutureOffset(3200)); const employee = await loginRole(page, "employee"); try { await (await openNavLink(page, "请假")).click(); await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); const leaveId = await createLeaveDraft(page, startDate, `R7E 浏览器请假 ${marker}`); state.leaveRequestId = leaveId; state.leaveDate = startDate; const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>(employeeApi.api, `/api/leave-requests/${leaveId}/submit`, {}); state.leaveTaskId = submitResult.currentTaskId; await page.goto("/leave-requests"); const row = page.getByRole("row").filter({ hasText: startDate }).first(); await expect(row).toContainText("审批中", { timeout: 30_000 }); await expect(row).toContainText("当前节点", { timeout: 30_000 }); await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); await approveTaskViaBrowser("manager", submitResult.currentTaskId, /对象类型:请假|leave|请假/); const employeeAgain = await loginRole(page, "employee"); await page.goto("/leave-requests"); await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/mobile/leave"); await expect(page.getByTestId("mobile-leave-records")).toBeVisible(); await expect(page.getByText(startDate).first()).toBeVisible({ timeout: 30_000 }); await checkNoHorizontalOverflow(page); await page.setViewportSize({ width: 1440, height: 960 }); await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined); return { businessId: `leave=${leaveId}; task=${submitResult.currentTaskId}`, pcResult: "PASS: 请假提交、审批、PC 回看已通过", mobileResult: "PASS: /mobile/leave 记录页可见且无横向滚动", detail: `leaveRequestId=${leaveId}; date=${startDate}`, }; } finally { await employeeApi.dispose(); await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined); } }); } if (shouldRunFlow(selectedFlow, "purchase-attachment")) { await runStep("采购", "employee/manager/financeMgr", "采购提交、审批、归档和移动详情回看", async () => { const employee = await loginRole(page, "employee"); try { await page.setViewportSize({ width: 1440, height: 960 }); await (await openNavLink(page, "采购申请")).click(); const createResponsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests", ); await page.getByTestId("purchase-requests-create").click(); const createResponse = await createResponsePromise; if (!createResponse.ok()) { throw new Error(`采购草稿创建失败:HTTP ${createResponse.status()} ${await createResponse.text()}`); } const createPayload = (await createResponse.json()) as { data?: { purchaseRequest?: { id?: string } } }; const purchaseRequestId = createPayload.data?.purchaseRequest?.id; if (!purchaseRequestId) { throw new Error(`创建采购申请后未返回 id: ${JSON.stringify(createPayload)}`); } state.purchaseRequestId = purchaseRequestId; await page.goto(`/purchase-requests?id=${purchaseRequestId}`); await expect(page.getByTestId("purchase-title")).toBeVisible(); await page.getByTestId("purchase-title").fill(`R7E 浏览器采购 ${marker}`); await selectFirstEnabledOption(page.getByTestId("purchase-legal-entity")); await selectFirstEnabledOption(page.getByTestId("purchase-cost-center")); await selectFirstEnabledOption(page.getByTestId("purchase-supplier")); await selectFirstEnabledOption(page.getByTestId("purchase-document-category")); const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]:visible'); if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) { await page.getByTestId("purchase-add-item").click(); } await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 }); await itemDescriptionInputs.first().fill(`R7E 采购明细 ${marker}`); await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("1"); await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188"); const purchaseAttachmentName = `r7e-purchase-attachment-${marker}.txt`; const uploadAttachmentResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`; }); await page.getByTestId("purchase-attachment-file-input").setInputFiles({ name: purchaseAttachmentName, mimeType: "text/plain", buffer: Buffer.from(`R7E purchase attachment ${marker}\n`, "utf-8"), }); const uploadAttachmentResponse = await uploadAttachmentResponsePromise; if (!uploadAttachmentResponse.ok()) { throw new Error(`采购附件上传失败:HTTP ${uploadAttachmentResponse.status()} ${await uploadAttachmentResponse.text()}`); } await expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 }); const submitResponsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`, ); await page.getByTestId("purchase-submit").click(); const submitResponse = await submitResponsePromise; if (!submitResponse.ok()) { throw new Error(`采购提交失败:HTTP ${submitResponse.status()} ${await submitResponse.text()}`); } const submitPayload = (await submitResponse.json()) as { data?: { currentTaskId?: string } }; const currentTaskId = submitPayload.data?.currentTaskId; if (!currentTaskId) { throw new Error(`采购提交后未返回 currentTaskId: ${JSON.stringify(submitPayload)}`); } state.purchaseTaskId = currentTaskId; state.purchaseRequestNo = (await page.getByRole("heading", { level: 3 }).filter({ hasText: /^CG-/ }).first().textContent())?.trim() ?? purchaseRequestId; await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 }); await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); const managerApi = await createLiveApiClientWithRetry("manager"); try { await approveWorkflowTaskViaApi(managerApi.api, currentTaskId); } finally { await managerApi.dispose(); } const employeeAgain = await loginRole(page, "employee"); await page.goto(`/purchase-requests?id=${purchaseRequestId}`); await expect(page.getByTestId("purchase-detail-status")).toContainText(/已通过|approved|已完成/, { timeout: 30_000 }); await expect(page.getByTestId("purchase-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 }); await expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/mobile/purchase-requests"); await expect(page.getByTestId("mobile-purchase-requests-list")).toBeVisible(); await checkNoHorizontalOverflow(page); await page.goto(`/mobile/purchase-requests/${purchaseRequestId}`); await expect(page.getByTestId("mobile-purchase-detail-summary")).toBeVisible(); await expect(page.getByTestId("mobile-purchase-detail-attachments")).toContainText(purchaseAttachmentName, { timeout: 30_000 }); await checkNoHorizontalOverflow(page); await page.setViewportSize({ width: 1440, height: 960 }); await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined); return { businessId: `${state.purchaseRequestNo}; task=${currentTaskId}`, pcResult: "PASS: 采购附件、提交、审批、状态和历史可追溯", mobileResult: "PASS: 移动采购列表、详情和附件可见", detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}; attachment=${purchaseAttachmentName}`, }; } finally { await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined); } }); } if (shouldRunFlow(selectedFlow, "reimbursement-finance")) { await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => { if (!state.reimbursementId || !state.reimbursementSubmitted) { return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少已提交报销", detail: "报销链路未产出可付款单据。" }; } const financeApi = await createLiveApiClientWithRetry("finance"); try { let financeTaskId = ""; try { financeTaskId = (await waitForWorkflowTask(financeApi.api, { entityType: "reimbursement", entityId: state.reimbursementId, attempts: 8, intervalMs: 1000, })).id; } catch { financeTaskId = ""; } if (financeTaskId) { await approveTaskViaBrowser("finance", financeTaskId, /对象类型:报销|reimbursement|报销/); } const reimbursementAfterApproval = await apiGetData(financeApi.api, `/api/reimbursements/${state.reimbursementId}`); if (!/approved|paid/.test(reimbursementAfterApproval.status)) { throw new Error(`报销未达到可付款状态:${reimbursementAfterApproval.status}`); } const finance = await loginRole(page, "finance"); await page.goto("/ap-operations"); await expect(page.getByRole("heading", { name: /AP 运营|财务付款与对账/ }).first()).toBeVisible(); if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { return { status: "SKIP", businessId: state.reimbursementCode ?? state.reimbursementId, pcResult: "SKIP: 财务付款功能开关未启用", conclusion: "暂停", detail: "报销审批已完成;/ap-operations 返回当前功能未启用,未创建付款批次或执行付款。", }; } await expect(page.getByTestId("finance-ledger-table")).toBeVisible(); await page.getByPlaceholder("单号、姓名、部门、项目").fill(state.reimbursementCode ?? state.reimbursementId); const ledgerHasReimbursement = await expect(page.getByTestId("finance-ledger-table")) .toContainText(state.reimbursementCode ?? state.reimbursementId, { timeout: 30_000 }) .then(() => true) .catch(() => false); if (!ledgerHasReimbursement) { return { status: "SKIP", businessId: state.reimbursementCode ?? state.reimbursementId, pcResult: "SKIP: 已审批报销未进入 AP 台账筛选结果", conclusion: "暂停", detail: `报销审批已完成;finance-ledger-table 按 ${state.reimbursementCode ?? state.reimbursementId} 筛选为空,未创建付款批次或执行付款。`, }; } const batchName = `R7E 浏览器付款批次 ${marker}`; state.paymentBatchName = batchName; await page.goto("/payment-batches"); await expect(page.getByRole("heading", { name: "支付批次", exact: true })).toBeVisible(); if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { return { status: "SKIP", businessId: state.reimbursementCode ?? state.reimbursementId, pcResult: "SKIP: 支付批次功能开关未启用", conclusion: "暂停", detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。", }; } const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first(); await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled(); const createBatchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/payment-batches"; }); await createCard.locator("select").nth(0).selectOption("reimbursement"); await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName); await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(state.reimbursementId); await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10)); await createCard.locator("select").nth(1).selectOption("bank"); await createCard.getByPlaceholder("例如 PBX").fill(`R7E-${marker.slice(-6)}`); await createCard.getByRole("button", { name: "创建批次" }).click(); const createBatchResponse = await createBatchResponsePromise; if (!createBatchResponse.ok()) { throw new Error(`创建支付批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`); } const createBatchPayload = (await createBatchResponse.json()) as { data?: { item?: PaymentBatch } }; const batch = createBatchPayload.data?.item; if (!batch?.id) { throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } state.paymentBatchId = batch.id; await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); const financeMgr = await loginRole(page, "financeMgr"); await page.goto("/ap-operations"); await page.getByRole("tab", { name: /待付款/ }).click(); const pendingRow = page.locator("tr").filter({ hasText: batchName }).first(); await expect(pendingRow).toBeVisible({ timeout: 30_000 }); await pendingRow.getByRole("button", { name: "快捷复核" }).click(); await expect(page.getByRole("dialog")).toContainText(batchName); await page.getByPlaceholder("请输入复核意见").fill(`R7E 浏览器复核 ${marker}`); await page.getByRole("button", { name: "提交复核" }).click(); await expect(pendingRow).toContainText(/复核 1\/1|复核 1\/2/, { timeout: 30_000 }); await logoutLiveUser(page, { name: financeMgr.name }).catch(() => undefined); const financeAgain = await loginRole(page, "finance"); await page.goto(`/payment-batches?id=${batch.id}`); const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last(); await expect(batchDetailCard).toBeVisible(); await batchDetailCard.getByRole("button", { name: "执行" }).click(); await expect(batchDetailCard).toContainText("状态:已执行", { timeout: 30_000 }); await page.goto(`/reimbursements/${state.reimbursementId}`); await reimbursementDetail.assertLoaded(); await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 }); await page.goto("/ap-operations"); await page.getByPlaceholder("单号、姓名、部门、项目").fill(state.reimbursementCode ?? state.reimbursementId); await expect(page.getByTestId("finance-ledger-table")).toContainText(/已付款|paid/, { timeout: 30_000 }); await logoutLiveUser(page, { name: financeAgain.name }).catch(() => undefined); const admin = await loginRole(page, "admin"); await page.goto("/payment-batches"); if (/\/403/.test(page.url())) { await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible(); } else { const createButton = page.getByRole("button", { name: "创建批次" }).first(); await expect(createButton).toBeDisabled(); } await logoutLiveUser(page, { name: admin.name }).catch(() => undefined); return { businessId: `batch=${batch.id}; reimbursement=${state.reimbursementCode ?? state.reimbursementId}`, pcResult: "PASS: 财务台账、付款批次、复核、执行和管理员边界已验证", detail: `paymentBatchId=${batch.id}; reimbursementId=${state.reimbursementId}; financeTask=${financeTaskId || "none"}`, }; } finally { await financeApi.dispose(); } }); } if (shouldRunFlow(selectedFlow, "mobile")) { await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => { let lastScreenshot = ""; const captureMobile = async (mobilePage: Page, role: LiveSmokeRole, url: string) => { const screenshotPath = path.join(screenshotDir, `${safeName(`mobile-${role}-${url}`)}.png`); await mobilePage.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined); lastScreenshot = path.relative(rootDir, screenshotPath); }; const employeeContext = await createMobileRolePage(browser, "employee"); try { const employeeTargets: Array<[string, string]> = [ ["/mobile", "mobile-action-grid"], ["/mobile/reimbursements", "mobile-reimbursements-list"], ["/mobile/leave", "mobile-leave-records"], ["/mobile/approvals", "mobile-approvals-list|mobile-approvals-progress"], ["/mobile/notifications", "mobile-notifications-page"], ["/mobile/purchase-requests", "mobile-purchase-requests-list"], ]; for (const [url, testId] of employeeTargets) { await employeeContext.page.goto(url); if (testId.includes("|")) { await expectAnyVisible(employeeContext.page, testId.split("|")); } else { await expect(employeeContext.page.getByTestId(testId)).toBeVisible({ timeout: 30_000 }); } await checkNoHorizontalOverflow(employeeContext.page); await captureMobile(employeeContext.page, "employee", url); } } finally { await employeeContext.context.close(); } const managerContext = await createMobileRolePage(browser, "manager"); try { await managerContext.page.goto("/mobile/approvals"); await expectAnyVisible(managerContext.page, ["mobile-approvals-list", "mobile-approvals-progress"]); await checkNoHorizontalOverflow(managerContext.page); await captureMobile(managerContext.page, "manager", "/mobile/approvals"); } finally { await managerContext.context.close(); } const financeContext = await createMobileRolePage(browser, "finance"); try { await financeContext.page.goto("/mobile"); await expect(financeContext.page.getByTestId("mobile-action-grid")).toBeVisible({ timeout: 30_000 }); await checkNoHorizontalOverflow(financeContext.page); await captureMobile(financeContext.page, "finance", "/mobile"); } finally { await financeContext.context.close(); } return { mobileResult: "PASS: 员工、审批人、财务移动入口可见且无横向滚动", pcResult: "-", detail: "checked /mobile, reimbursements, leave, approvals, notifications, purchase requests with browser storage state", screenshot: lastScreenshot, }; }); } if (shouldRunFlow(selectedFlow, "wecom-ua")) { await runStep("企业微信UA", "anonymous", "企业微信 UA 模拟入口和 OAuth 边界可达", async () => { const context = await browser.newContext({ baseURL: process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com", viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: wecomUa, locale: "zh-CN", serviceWorkers: "block", }); const wecomPage = await context.newPage(); let lastScreenshot = ""; try { const configResponse = await wecomPage.request.get(liveUrl("/api/auth/wecom/config")); expect(configResponse.ok()).toBeTruthy(); const configPayload = await configResponse.json() as { data?: { entryEnabled?: boolean; oauthEnabled?: boolean; loginUrl?: string } }; expect(configPayload.data?.entryEnabled).toBeTruthy(); expect(configPayload.data?.oauthEnabled).toBeTruthy(); expect(configPayload.data?.loginUrl ?? "").toContain("/api/auth/wecom/login"); await wecomPage.goto(liveUrl("/login?redirect=%2Fmobile")); await expect(wecomPage.getByTestId("login-wecom-primary-panel")).toBeVisible({ timeout: 30_000 }); await expect(wecomPage.getByTestId("wecom-entry-primary")).toBeVisible({ timeout: 30_000 }); await expect(wecomPage.locator("aside")).toHaveCount(0); await checkNoHorizontalOverflow(wecomPage); const screenshotPath = path.join(screenshotDir, "wecom-ua-login-entry.png"); await wecomPage.screenshot({ path: screenshotPath, fullPage: true }); lastScreenshot = path.relative(rootDir, screenshotPath); return { pcResult: "-", mobileResult: "PASS: 企业微信 UA 模拟登录入口、配置和 OAuth 边界可达", detail: "WeCom UA checked login entry and /api/auth/wecom/config;不代表真机免登、消息弹窗或 WebView 文件选择器验收。", screenshot: lastScreenshot, }; } finally { await context.close(); } }); } } finally { writeReports(steps); } const failures = steps.filter((item) => item.status === "FAIL"); expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); });