R7E 无人工操作自动化证据回填

这个提交包含在:
2026-05-14 16:09:38 +08:00
父节点 5afb8f9ab1
当前提交 b437743ef1
共修改 6 个文件,包含 408 行新增和 110 行删除
+42
查看文件
@@ -157,6 +157,48 @@ export async function createAuthenticatedApiClient(user: E2EUser, baseURL = E2E_
};
}
export async function createAuthenticatedBrowserStorageState(user: E2EUser, baseURL = E2E_BASE_URL) {
const deviceFingerprint = buildDeviceFingerprint(user);
const loginContext = await playwrightRequest.newContext({
baseURL,
extraHTTPHeaders: {
"x-device-fingerprint": deviceFingerprint,
},
});
try {
const loginResponse = await loginContext.post("/api/auth/login", {
data: {
identifier: user.identifier,
password: user.password,
},
});
await readJson<{ accessToken: string }>(loginResponse);
const state = await loginContext.storageState();
const origin = new URL(baseURL).origin;
const originState = state.origins.find((item) => item.origin === origin);
const deviceFingerprintEntry = {
name: "hyb_oa_device_fingerprint",
value: deviceFingerprint,
};
if (originState) {
originState.localStorage = originState.localStorage.filter((item) => item.name !== deviceFingerprintEntry.name);
originState.localStorage.push(deviceFingerprintEntry);
} else {
state.origins.push({
origin,
localStorage: [deviceFingerprintEntry],
});
}
return state;
} finally {
await loginContext.dispose();
}
}
export async function apiGetData<T>(api: APIRequestContext, path: string) {
const response = await api.get(path);
return readJson<T>(response);
+12 -2
查看文件
@@ -1,6 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { createAuthenticatedApiClient, logout, type AuthenticatedApiClient, type E2EUser } from "./auth";
import { createAuthenticatedApiClient, createAuthenticatedBrowserStorageState, logout, type AuthenticatedApiClient, type E2EUser } from "./auth";
export type LiveSmokeRole = "admin" | "employee" | "manager" | "finance" | "financeMgr";
@@ -19,6 +19,11 @@ function requireEnv(key: string) {
return value;
}
function liveUrl(pathname: string) {
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim();
return baseURL ? new URL(pathname, baseURL).toString() : pathname;
}
export function getExpectedWecomStatus() {
return process.env.LIVE_EXPECT_WECOM_STATUS?.trim() || "disabled";
}
@@ -68,7 +73,7 @@ async function readCurrentUserName(page: Page): Promise<string> {
export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise<LiveSmokeUser> {
const credentials = getLiveSmokeUser(role);
await page.goto("/login");
await page.goto(liveUrl("/login"));
await expect(page.getByTestId("login-identifier")).toBeVisible();
await page.getByTestId("login-identifier").fill(credentials.identifier);
await page.getByTestId("login-password").fill(credentials.password);
@@ -112,3 +117,8 @@ export async function createLiveApiClient(role: LiveSmokeRole): Promise<Authenti
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
return createAuthenticatedApiClient(getRequiredLiveUser(role), baseURL);
}
export async function createLiveBrowserStorageState(role: LiveSmokeRole) {
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
return createAuthenticatedBrowserStorageState(getRequiredLiveUser(role), baseURL);
}
@@ -1,16 +1,17 @@
import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
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, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth";
import { createLiveApiClient, createLiveBrowserStorageState, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth";
import { openNavLink } from "../helpers/navigation";
import { TEST_IDS } from "../helpers/testIds";
import { waitForWorkflowTask } from "../helpers/workflow";
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workflow";
type StepStatus = "PASS" | "FAIL" | "SKIP";
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动";
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动" | "企业微信UA";
type FlowKey = "preflight" | "reimbursement-finance" | "leave" | "purchase-attachment" | "mobile" | "wecom-ua" | "all";
type TrialStep = {
flow: FlowName;
@@ -26,7 +27,7 @@ type TrialStep = {
screenshot: string;
};
type StepResult = string | Partial<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail">>;
type StepResult = string | Partial<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail" | "screenshot">>;
type ReimbursementDetail = {
id: string;
@@ -71,6 +72,10 @@ 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<FlowKey>(["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());
@@ -86,6 +91,18 @@ 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);
}
@@ -102,6 +119,33 @@ 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)) {
@@ -222,6 +266,7 @@ async function createLeaveDraft(page: Page, startDate: string, reason: string) {
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 } };
@@ -251,13 +296,64 @@ async function expectAnyVisible(page: Page, testIds: string[]) {
}
async function loginRole(page: Page, role: LiveSmokeRole) {
await page.context().clearCookies();
await page.goto("/login");
await page.evaluate(() => {
window.localStorage.clear();
window.sessionStorage.clear();
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",
});
return loginLiveBrowserUser(page, role);
const mobilePage = await context.newPage();
return { context, page: mobilePage };
}
function writeReports(steps: TrialStep[]) {
@@ -265,11 +361,15 @@ function writeReports(steps: TrialStep[]) {
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}`,
"",
"## 结论",
"",
@@ -281,7 +381,7 @@ function writeReports(steps: TrialStep[]) {
"",
"## 固定口径",
"",
"- 本报告只覆盖公网 PC/移动浏览器业务线。",
"- 本报告只覆盖公网 PC/移动浏览器业务线和企业微信 UA 模拟补测。",
"- 企业微信免登录、企业微信消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍需单独验收。",
"- 报告不记录密码、token、票据内容或私人联系方式。",
"",
@@ -301,7 +401,9 @@ function writeReports(steps: TrialStep[]) {
`| ${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 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) =>
@@ -312,24 +414,27 @@ function writeReports(steps: TrialStep[]) {
item.title,
item.detail,
item.screenshot || "N/A",
item.flow === "移动" ? "QAOPS8/UX8" : "R7E/WF8/BIZ8",
item.flow === "移动" || item.flow === "企业微信UA" ? "QAOPS8/UX8/R7E" : "R7E/WF8/BIZ8",
"OPEN",
"由 R7E 浏览器业务先行试运行生成,需人工复核严重级别。",
].map(csvCell).join(","),
);
writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8");
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, reimbursementDetail, workflowDetail }) => {
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(600_000);
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<StepResult | void>) {
@@ -339,7 +444,9 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
try {
const rawResult = await action();
const result = typeof rawResult === "string" || !rawResult ? { detail: rawResult || "ok" } : rawResult;
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
if (!result.screenshot) {
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
}
steps.push({
flow,
role,
@@ -351,7 +458,7 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
issueId: result.issueId ?? "-",
conclusion: result.conclusion ?? "通过",
detail: result.detail ?? "ok",
screenshot: relativeScreenshot,
screenshot: result.screenshot ?? relativeScreenshot,
});
} catch (error) {
const detail = stripAnsi(error instanceof Error ? error.message : String(error));
@@ -401,21 +508,46 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
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 (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;
}
await runStep("报销", "employee", "创建报销、上传授权 PDF 并提交", async () => {
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<ReimbursementDetail>(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 {
@@ -426,7 +558,7 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
};
}
const employeeApi = await createLiveApiClient("employee");
const employeeApi = await createLiveApiClientWithRetry("employee");
const user = await loginRole(page, "employee");
try {
await (await openNavLink(page, "我的报销")).click();
@@ -499,12 +631,13 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
if (!state.reimbursementSubmitted || !state.reimbursementTaskId) {
return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 报销未提交", detail: "报销授权 PDF 样票不可绑定,未产生报销审批待办。" };
}
const managerApi = await createLiveApiClient("manager");
const employeeUser = await loginRole(page, "employee");
let managerApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
try {
const taskId = state.reimbursementTaskId
?? (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id;
await logoutLiveUser(page, { name: employeeUser.name }).catch(() => undefined);
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");
@@ -518,13 +651,16 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
detail: `approved reimbursement task=${taskId}`,
};
} finally {
await managerApi.dispose();
await managerApi?.dispose();
}
});
}
}
await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => {
const employeeApi = await createLiveApiClient("employee");
const startDate = formatDate(uniqueFutureOffset(3200));
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();
@@ -565,8 +701,10 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
}
});
}
await runStep("采购", "employee/manager/financeMgr", "采购提交、审批、归档和移动详情回看", async () => {
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 });
@@ -602,6 +740,22 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
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`,
);
@@ -620,53 +774,48 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 });
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
await approveTaskViaBrowser("manager", currentTaskId, /对象类型:采购申请|采购/);
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 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 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: mobileEmployee.name }).catch(() => undefined);
await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined);
return {
businessId: `${state.purchaseRequestNo}; task=${currentTaskId}`,
pcResult: "PASS: 采购提交、审批、状态和历史可追溯",
mobileResult: "PASS: 移动采购列表和详情可见",
detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}`,
pcResult: "PASS: 采购附件、提交、审批、状态和历史可追溯",
mobileResult: "PASS: 移动采购列表、详情和附件可见",
detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}; attachment=${purchaseAttachmentName}`,
};
} finally {
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
}
});
}
await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => {
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 createLiveApiClient("finance");
const financeApi = await createLiveApiClientWithRetry("finance");
try {
let financeTaskId = "";
try {
@@ -691,15 +840,45 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
const finance = await loginRole(page, "finance");
await page.goto("/ap-operations");
await expect(page.getByRole("heading", { name: "AP 运营", exact: true })).toBeVisible();
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);
await expect(page.getByTestId("finance-ledger-table")).toContainText(state.reimbursementCode ?? state.reimbursementId, { timeout: 30_000 });
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) => {
@@ -770,51 +949,112 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
await financeApi.dispose();
}
});
}
await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => {
const employee = await loginRole(page, "employee");
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 {
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"],
["/mobile/purchase-requests", "mobile-purchase-requests-list"],
];
for (const [url, testId] of employeeTargets) {
await page.goto(url);
await employeeContext.page.goto(url);
if (testId.includes("|")) {
await expectAnyVisible(page, testId.split("|"));
await expectAnyVisible(employeeContext.page, testId.split("|"));
} else {
await expect(page.getByTestId(testId)).toBeVisible({ timeout: 30_000 });
await expect(employeeContext.page.getByTestId(testId)).toBeVisible({ timeout: 30_000 });
}
await checkNoHorizontalOverflow(page);
await checkNoHorizontalOverflow(employeeContext.page);
await captureMobile(employeeContext.page, "employee", url);
}
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
} finally {
await employeeContext.context.close();
}
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 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();
}
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",
detail: "checked /mobile, reimbursements, leave, approvals, notifications, purchase requests with browser storage state",
screenshot: lastScreenshot,
};
} finally {
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
}
});
}
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);
}