R7E 浏览器业务先行试运行证据回填

这个提交包含在:
2026-05-14 11:44:02 +08:00
父节点 352cb365ad
当前提交 5afb8f9ab1
共修改 5 个文件,包含 901 行新增和 19 行删除
+54 -12
查看文件
@@ -1,8 +1,8 @@
import { expect, type Page } from "@playwright/test";
import { logout } from "./auth";
import { createAuthenticatedApiClient, logout, type AuthenticatedApiClient, type E2EUser } from "./auth";
export type LiveSmokeRole = "admin" | "employee";
export type LiveSmokeRole = "admin" | "employee" | "manager" | "finance" | "financeMgr";
export type LiveSmokeUser = {
role: LiveSmokeRole;
@@ -24,18 +24,34 @@ export function getExpectedWecomStatus() {
}
export function getLiveSmokeUser(role: LiveSmokeRole) {
if (role === "admin") {
return {
role,
identifier: requireEnv("LIVE_SMOKE_ADMIN_IDENTIFIER"),
password: requireEnv("LIVE_SMOKE_ADMIN_PASSWORD"),
};
}
const envByRole: Record<LiveSmokeRole, { identifier: string; password: string }> = {
admin: {
identifier: "LIVE_SMOKE_ADMIN_IDENTIFIER",
password: "LIVE_SMOKE_ADMIN_PASSWORD",
},
employee: {
identifier: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
password: "LIVE_SMOKE_EMPLOYEE_PASSWORD",
},
manager: {
identifier: "LIVE_SMOKE_MANAGER_IDENTIFIER",
password: "LIVE_SMOKE_MANAGER_PASSWORD",
},
finance: {
identifier: "LIVE_SMOKE_FINANCE_IDENTIFIER",
password: "LIVE_SMOKE_FINANCE_PASSWORD",
},
financeMgr: {
identifier: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
password: "LIVE_SMOKE_FINANCE_MGR_PASSWORD",
},
};
const env = envByRole[role];
return {
role,
identifier: requireEnv("LIVE_SMOKE_EMPLOYEE_IDENTIFIER"),
password: requireEnv("LIVE_SMOKE_EMPLOYEE_PASSWORD"),
identifier: requireEnv(env.identifier),
password: requireEnv(env.password),
};
}
@@ -57,7 +73,14 @@ export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise<Li
await page.getByTestId("login-identifier").fill(credentials.identifier);
await page.getByTestId("login-password").fill(credentials.password);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await expect(page).toHaveURL(/\/portal/, { timeout: 30_000 }).catch(async (error) => {
const loginError = page.locator(".text-red-600, .text-red-700, [role='alert']").first();
const message = (await loginError.textContent().catch(() => ""))?.trim();
if (message) {
throw new Error(`live 登录失败:${message}`);
}
throw error;
});
const name = await readCurrentUserName(page);
@@ -70,3 +93,22 @@ export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise<Li
export async function logoutLiveUser(page: Page, user?: Pick<LiveSmokeUser, "name">) {
await logout(page, user?.name);
}
export function getRequiredLiveUser(role: LiveSmokeRole): E2EUser {
const credentials = getLiveSmokeUser(role);
return {
identifier: credentials.identifier,
password: credentials.password,
name: role,
};
}
export async function loginLiveBrowserUser(page: Page, role: LiveSmokeRole): Promise<LiveSmokeUser> {
return loginLiveUser(page, role);
}
export async function createLiveApiClient(role: LiveSmokeRole): Promise<AuthenticatedApiClient> {
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
return createAuthenticatedApiClient(getRequiredLiveUser(role), baseURL);
}
@@ -0,0 +1,824 @@
import { expect, type APIRequestContext, 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, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth";
import { openNavLink } from "../helpers/navigation";
import { TEST_IDS } from "../helpers/testIds";
import { waitForWorkflowTask } from "../helpers/workflow";
type StepStatus = "PASS" | "FAIL" | "SKIP";
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动";
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<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail">>;
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");
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 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 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 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) {
await page.context().clearCookies();
await page.goto("/login");
await page.evaluate(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});
return loginLiveBrowserUser(page, role);
}
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 lines = [
"# R7E 浏览器业务先行试运行报告",
"",
`生成时间:${generatedAt}`,
"",
"## 结论",
"",
failures.length
? `发现 ${failures.length} 个失败步骤,需分流到 R7E/R7E-16 或 QAOPS8。`
: skips.length
? `未发现失败步骤,但有 ${skips.length} 个 SKIP 步骤,不能据此关闭对应人工验收。`
: "公网浏览器业务线自动辅助试运行未发现阻断步骤。",
"",
"## 固定口径",
"",
"- 本报告只覆盖公网 PC/移动浏览器业务线。",
"- 企业微信免登录、企业微信消息送达、消息 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)} |`,
),
];
writeFileSync(reportPath, `${lines.join("\n")}\n`, "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 === "移动" ? "QAOPS8/UX8" : "R7E/WF8/BIZ8",
"OPEN",
"由 R7E 浏览器业务先行试运行生成,需人工复核严重级别。",
].map(csvCell).join(","),
);
writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8");
}
test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器", async ({ page, 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(600_000);
mkdirSync(screenshotDir, { recursive: true });
const steps: TrialStep[] = [];
const state: TrialState = {};
const marker = nowStamp();
let stepIndex = 0;
async function runStep(flow: FlowName, role: string, title: string, action: () => Promise<StepResult | void>) {
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;
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: 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()}`;
});
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;
}
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 createLiveApiClient("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<ReimbursementDetail>(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 样票不可绑定,未产生报销审批待办。" };
}
const managerApi = await createLiveApiClient("manager");
const employeeUser = await loginRole(page, "employee");
try {
const taskId = state.reimbursementTaskId
?? (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id;
await logoutLiveUser(page, { name: employeeUser.name }).catch(() => undefined);
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();
}
});
await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => {
const employeeApi = await createLiveApiClient("employee");
const startDate = formatDate(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);
}
});
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 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);
await approveTaskViaBrowser("manager", currentTaskId, /对象类型:采购申请|采购/);
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 logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined);
const financeMgr = await loginRole(page, "financeMgr");
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
const archiveInput = page.getByTestId("purchase-archive-po-no");
if (await archiveInput.isVisible().catch(() => false)) {
await archiveInput.fill(`PO-R7E-${marker}`);
await page.getByTestId("purchase-archive-contract-no").fill(`CT-R7E-${marker}`);
await page.getByTestId("purchase-archive-save").click();
await expect(archiveInput).toHaveValue(`PO-R7E-${marker}`, { timeout: 30_000 });
}
await logoutLiveUser(page, { name: financeMgr.name }).catch(() => undefined);
const mobileEmployee = await loginRole(page, "employee");
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 checkNoHorizontalOverflow(page);
await page.setViewportSize({ width: 1440, height: 960 });
await logoutLiveUser(page, { name: mobileEmployee.name }).catch(() => undefined);
return {
businessId: `${state.purchaseRequestNo}; task=${currentTaskId}`,
pcResult: "PASS: 采购提交、审批、状态和历史可追溯",
mobileResult: "PASS: 移动采购列表和详情可见",
detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}`,
};
} finally {
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
}
});
await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => {
if (!state.reimbursementId || !state.reimbursementSubmitted) {
return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少已提交报销", detail: "报销链路未产出可付款单据。" };
}
const financeApi = await createLiveApiClient("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<ReimbursementDetail>(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 运营", exact: true })).toBeVisible();
await expect(page.getByTestId("finance-ledger-table")).toBeVisible();
await page.getByPlaceholder("单号、姓名、部门、项目").fill(state.reimbursementCode ?? state.reimbursementId);
await expect(page.getByTestId("finance-ledger-table")).toContainText(state.reimbursementCode ?? state.reimbursementId, { timeout: 30_000 });
const batchName = `R7E 浏览器付款批次 ${marker}`;
state.paymentBatchName = batchName;
await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "支付批次", exact: true })).toBeVisible();
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();
}
});
await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => {
const employee = await loginRole(page, "employee");
try {
await page.setViewportSize({ width: 390, height: 844 });
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"],
];
for (const [url, testId] of employeeTargets) {
await page.goto(url);
if (testId.includes("|")) {
await expectAnyVisible(page, testId.split("|"));
} else {
await expect(page.getByTestId(testId)).toBeVisible({ timeout: 30_000 });
}
await checkNoHorizontalOverflow(page);
}
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
const manager = await loginRole(page, "manager");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/approvals");
await expectAnyVisible(page, ["mobile-approvals-list", "mobile-approvals-progress"]);
await checkNoHorizontalOverflow(page);
await logoutLiveUser(page, { name: manager.name }).catch(() => undefined);
const finance = await loginRole(page, "finance");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
await checkNoHorizontalOverflow(page);
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
return {
mobileResult: "PASS: 员工、审批人、财务移动入口可见且无横向滚动",
pcResult: "-",
detail: "checked /mobile, reimbursements, leave, approvals, notifications",
};
} finally {
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
}
});
} finally {
writeReports(steps);
}
const failures = steps.filter((item) => item.status === "FAIL");
expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0);
});