1359 行
64 KiB
TypeScript
1359 行
64 KiB
TypeScript
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"
|
||
| "finance-closeout"
|
||
| "approval-boundary"
|
||
| "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<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail" | "screenshot">>;
|
||
|
||
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;
|
||
items?: Array<{
|
||
entityId: string;
|
||
entityCode: string;
|
||
status: string;
|
||
}>;
|
||
reviewRequirement: {
|
||
completedReviewCount: number;
|
||
requiredReviewCount: number;
|
||
nextStep: string;
|
||
};
|
||
};
|
||
|
||
type FinanceLedgerItem = {
|
||
id: string;
|
||
formNumber: string | null;
|
||
status: string;
|
||
payment: {
|
||
status: string;
|
||
paymentVoucherNo: string | null;
|
||
paidAt: string | null;
|
||
};
|
||
};
|
||
|
||
type FinanceLedgerResponse = {
|
||
total: number;
|
||
items: FinanceLedgerItem[];
|
||
};
|
||
|
||
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 trialrunIssueId = process.env.TRIALRUN_ISSUE_ID?.trim() || process.env.QA_ISSUE_ID?.trim() || "R7E";
|
||
const trialrunMarkerPrefix = process.env.TRIALRUN_MARKER_PREFIX?.trim() || trialrunIssueId;
|
||
const trialrunSafePrefix = safeName(trialrunMarkerPrefix).toUpperCase().slice(0, 12) || "R7E";
|
||
const trialrunReportTitle = process.env.TRIALRUN_REPORT_TITLE?.trim() || `${trialrunMarkerPrefix} 浏览器业务先行试运行报告`;
|
||
function resolveRootOutputPath(value: string | undefined, fallback: string) {
|
||
const trimmed = value?.trim();
|
||
if (!trimmed) return fallback;
|
||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
||
}
|
||
const reportDir = resolveRootOutputPath(
|
||
process.env.TRIALRUN_REPORT_DIR,
|
||
path.join(rootDir, "artifacts/r7e/browser-business-trialrun"),
|
||
);
|
||
const reportPath = path.join(reportDir, "report.md");
|
||
const issueCandidatesPath = path.join(reportDir, "issues.csv");
|
||
const screenshotDir = resolveRootOutputPath(
|
||
process.env.TRIALRUN_SCREENSHOT_DIR,
|
||
path.join(rootDir, "output/playwright/r7e-browser-business-trialrun"),
|
||
);
|
||
const validFlows = new Set<FlowKey>([
|
||
"preflight",
|
||
"reimbursement-finance",
|
||
"leave",
|
||
"purchase-attachment",
|
||
"mobile",
|
||
"wecom-ua",
|
||
"finance-closeout",
|
||
"approval-boundary",
|
||
"all",
|
||
]);
|
||
const loginRateLimitRetryMs = Number.parseInt(process.env.R7E_LOGIN_RETRY_WAIT_MS ?? "65000", 10);
|
||
const trialrunTestTimeoutMs = Number.parseInt(process.env.TRIALRUN_TEST_TIMEOUT_MS ?? "900000", 10);
|
||
const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
|
||
const defaultCloseoutReimbursementCode = "BX-20260514-0016";
|
||
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";
|
||
const safePurchaseAttachmentPng = Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
||
"base64",
|
||
);
|
||
|
||
function nowStamp() {
|
||
return `${trialrunMarkerPrefix}-${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 responseTextSummary(response: { status(): number; text(): Promise<string> }) {
|
||
const raw = await response.text().catch(() => "");
|
||
if (!raw) return `HTTP ${response.status()}`;
|
||
try {
|
||
const parsed = JSON.parse(raw) as { error?: { code?: string; message?: string; details?: unknown } };
|
||
const code = parsed.error?.code ?? `HTTP_${response.status()}`;
|
||
const message = parsed.error?.message ?? "";
|
||
const details = parsed.error?.details ? ` details=${JSON.stringify(parsed.error.details)}` : "";
|
||
return `${code}: ${message}${details}`.slice(0, 500);
|
||
} catch {
|
||
return `HTTP ${response.status()} ${raw.slice(0, 300)}`;
|
||
}
|
||
}
|
||
|
||
async function expectApiRejected(response: { ok(): boolean; status(): number; text(): Promise<string> }, label: string) {
|
||
if (response.ok()) {
|
||
throw new Error(`${label} 未被拒绝:HTTP ${response.status()}`);
|
||
}
|
||
return responseTextSummary(response);
|
||
}
|
||
|
||
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 fetchFinanceLedger(api: APIRequestContext, query: { q?: string; page?: number; pageSize?: number } = {}) {
|
||
const params = new URLSearchParams();
|
||
params.set("page", String(query.page ?? 1));
|
||
params.set("pageSize", String(query.pageSize ?? 20));
|
||
if (query.q) params.set("q", query.q);
|
||
return apiGetData<FinanceLedgerResponse>(api, `/api/ap-operations/reimbursement-ledger?${params.toString()}`);
|
||
}
|
||
|
||
async function fetchPaymentBatches(api: APIRequestContext) {
|
||
return apiGetData<{ items: PaymentBatch[] }>(api, "/api/payment-batches");
|
||
}
|
||
|
||
async function findPaymentBatchByEntity(api: APIRequestContext, entityId: string) {
|
||
const batches = await fetchPaymentBatches(api);
|
||
return batches.items.find((batch) => batch.items?.some((item) => item.entityId === entityId)) ?? null;
|
||
}
|
||
|
||
async function createPaymentBatchViaApi(api: APIRequestContext, input: {
|
||
reimbursementId: string;
|
||
name: string;
|
||
marker: string;
|
||
}) {
|
||
return apiPostData<{ item: PaymentBatch }>(api, "/api/payment-batches", {
|
||
entityType: "reimbursement",
|
||
name: input.name,
|
||
entityIds: [input.reimbursementId],
|
||
defaultPaymentDate: new Date().toISOString().slice(0, 10),
|
||
defaultPaymentChannel: "bank",
|
||
voucherPrefix: `${trialrunSafePrefix}-PAY-${input.marker.slice(-8)}`,
|
||
});
|
||
}
|
||
|
||
async function reviewPaymentBatchViaApi(api: APIRequestContext, batchId: string, marker: string) {
|
||
return apiPostData<{ item: PaymentBatch }>(api, `/api/payment-batches/${batchId}/review`, {
|
||
comment: `${trialrunMarkerPrefix} 非人工付款复核 ${marker}`,
|
||
});
|
||
}
|
||
|
||
async function executePaymentBatchViaApi(api: APIRequestContext, batchId: string) {
|
||
return apiPostData<{ item: PaymentBatch }>(api, `/api/payment-batches/${batchId}/execute`, {});
|
||
}
|
||
|
||
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: `${trialrunMarkerPrefix} 浏览器报销 ${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 = [
|
||
`# ${trialrunReportTitle}`,
|
||
"",
|
||
`生成时间:${generatedAt}`,
|
||
`执行 flow:${flowName}`,
|
||
`Issue:${trialrunIssueId}`,
|
||
`测试数据标记:${trialrunMarkerPrefix}`,
|
||
"",
|
||
"## 结论",
|
||
"",
|
||
failures.length
|
||
? `发现 ${failures.length} 个失败步骤,需分流到 ${trialrunIssueId} 或后续 Issue。`
|
||
: 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) =>
|
||
[
|
||
`${trialrunSafePrefix}-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" ? `QAOPS/UX/${trialrunIssueId}` : `${trialrunIssueId}/WF/BIZ`,
|
||
"OPEN",
|
||
`由 ${trialrunIssueId} 浏览器业务先行试运行生成,需人工复核严重级别。`,
|
||
].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(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器`, 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", `仅在 ${trialrunIssueId} 浏览器业务试运行模式下执行`);
|
||
test.setTimeout(Number.isFinite(trialrunTestTimeoutMs) && trialrunTestTimeoutMs > 0 ? trialrunTestTimeoutMs : 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>) {
|
||
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, "finance-closeout")) {
|
||
await runStep("财务", "finance/financeMgr/admin", "AP API 诊断、付款闭环和管理员边界", async () => {
|
||
const reimbursementId = process.env.R7E_EXISTING_REIMBURSEMENT_ID?.trim() || defaultCloseoutReimbursementId;
|
||
const reimbursementCode = process.env.R7E_EXISTING_REIMBURSEMENT_CODE?.trim() || defaultCloseoutReimbursementCode;
|
||
const financeApi = await createLiveApiClientWithRetry("finance");
|
||
let financeMgrApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
|
||
let adminApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
|
||
try {
|
||
const reimbursementBefore = await apiGetData<ReimbursementDetail>(financeApi.api, `/api/reimbursements/${reimbursementId}`);
|
||
const ledgerResponse = await financeApi.api.get(`/api/ap-operations/reimbursement-ledger?q=${encodeURIComponent(reimbursementCode)}&page=1&pageSize=20`);
|
||
const paymentBatchesResponse = await financeApi.api.get("/api/payment-batches");
|
||
let paymentBatchesSummary = "";
|
||
if (paymentBatchesResponse.ok()) {
|
||
const paymentBatchesPayload = (await paymentBatchesResponse.json()) as { data?: { items?: PaymentBatch[] } };
|
||
const matchedBatch = paymentBatchesPayload.data?.items?.find((batch) => batch.items?.some((item) => item.entityId === reimbursementId));
|
||
paymentBatchesSummary = matchedBatch ? `matchedBatch=${matchedBatch.id}; status=${matchedBatch.status}` : "matchedBatch=none";
|
||
} else {
|
||
paymentBatchesSummary = `paymentBatches=${await responseTextSummary(paymentBatchesResponse)}`;
|
||
}
|
||
if (!ledgerResponse.ok()) {
|
||
return {
|
||
status: "SKIP",
|
||
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}`,
|
||
pcResult: "SKIP: AP 台账 API 不可用",
|
||
conclusion: "暂停",
|
||
detail: `reimbursementStatus=${reimbursementBefore.status}; apLedger=${await responseTextSummary(ledgerResponse)}; ${paymentBatchesSummary}`,
|
||
};
|
||
}
|
||
|
||
const ledgerPayload = (await ledgerResponse.json()) as { data?: FinanceLedgerResponse };
|
||
const ledger = ledgerPayload.data;
|
||
const ledgerItem = ledger?.items?.find((item) => item.id === reimbursementId || item.formNumber === reimbursementCode) ?? null;
|
||
if (!ledgerItem) {
|
||
return {
|
||
status: "SKIP",
|
||
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}`,
|
||
pcResult: "SKIP: 已审批报销未进入 AP 台账 API 结果",
|
||
conclusion: "暂停",
|
||
detail: `reimbursementStatus=${reimbursementBefore.status}; apLedgerTotal=${ledger?.total ?? "unknown"}; apLedgerMatched=false`,
|
||
};
|
||
}
|
||
|
||
state.reimbursementId = reimbursementId;
|
||
state.reimbursementCode = reimbursementCode;
|
||
|
||
let batch: PaymentBatch | null = null;
|
||
if (reimbursementBefore.status === "paid" || ledgerItem.payment.status === "paid") {
|
||
batch = await findPaymentBatchByEntity(financeApi.api, reimbursementId).catch(() => null);
|
||
} else {
|
||
if (!/approved/.test(reimbursementBefore.status)) {
|
||
return {
|
||
status: "SKIP",
|
||
businessId: `${reimbursementCode}; status=${reimbursementBefore.status}`,
|
||
pcResult: "SKIP: 报销未处于可付款状态",
|
||
conclusion: "暂停",
|
||
detail: `reimbursementStatus=${reimbursementBefore.status}; ledgerStatus=${ledgerItem.status}; paymentStatus=${ledgerItem.payment.status}`,
|
||
};
|
||
}
|
||
|
||
const existingBatch = await findPaymentBatchByEntity(financeApi.api, reimbursementId).catch(() => null);
|
||
if (existingBatch && ["draft", "reviewed", "failed"].includes(existingBatch.status)) {
|
||
batch = existingBatch;
|
||
} else {
|
||
const batchName = `${trialrunMarkerPrefix} 非人工付款批次 ${marker}`;
|
||
const created = await createPaymentBatchViaApi(financeApi.api, { reimbursementId, name: batchName, marker });
|
||
batch = created.item;
|
||
}
|
||
|
||
if (batch.status === "draft") {
|
||
financeMgrApi = await createLiveApiClientWithRetry("financeMgr");
|
||
const reviewed = await reviewPaymentBatchViaApi(financeMgrApi.api, batch.id, marker);
|
||
batch = reviewed.item;
|
||
}
|
||
|
||
if (["reviewed", "failed"].includes(batch.status)) {
|
||
const executed = await executePaymentBatchViaApi(financeApi.api, batch.id);
|
||
batch = executed.item;
|
||
}
|
||
}
|
||
|
||
const reimbursementAfter = await apiGetData<ReimbursementDetail>(financeApi.api, `/api/reimbursements/${reimbursementId}`);
|
||
const finalLedger = await fetchFinanceLedger(financeApi.api, { q: reimbursementCode });
|
||
const finalLedgerItem = finalLedger.items.find((item) => item.id === reimbursementId || item.formNumber === reimbursementCode) ?? null;
|
||
if (reimbursementAfter.status !== "paid" || finalLedgerItem?.payment.status !== "paid") {
|
||
throw new Error(
|
||
`付款闭环后状态不一致:reimbursement=${reimbursementAfter.status}; ledgerPayment=${finalLedgerItem?.payment.status ?? "missing"}`,
|
||
);
|
||
}
|
||
|
||
adminApi = await createLiveApiClientWithRetry("admin");
|
||
const adminExecuteProbe = await adminApi.api.post("/api/payment-batches/not-a-valid-uuid/execute", { data: {} });
|
||
const adminBoundary = await expectApiRejected(adminExecuteProbe, "admin-only 执行付款探测");
|
||
if (adminExecuteProbe.status() !== 403) {
|
||
throw new Error(`admin-only 付款权限边界不是 403:${adminBoundary}`);
|
||
}
|
||
|
||
const finance = await loginRole(page, "finance");
|
||
try {
|
||
await page.goto("/ap-operations");
|
||
await expect(page.getByTestId("finance-ledger-table")).toBeVisible({ timeout: 30_000 });
|
||
await page.getByPlaceholder("单号、姓名、部门、项目").fill(reimbursementCode);
|
||
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementCode, { timeout: 30_000 });
|
||
await expect(page.getByTestId("finance-ledger-table")).toContainText(/已付款|paid/, { timeout: 30_000 });
|
||
} finally {
|
||
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
|
||
}
|
||
|
||
return {
|
||
businessId: `${reimbursementCode}; reimbursement=${reimbursementId}; batch=${batch?.id ?? "already-paid"}`,
|
||
pcResult: "PASS: AP API 可见、付款批次复核执行、报销和台账已付款、管理员付款权限被拒绝",
|
||
detail: `reimbursementStatus=${reimbursementAfter.status}; ledgerPayment=${finalLedgerItem.payment.status}; batchStatus=${batch?.status ?? "already-paid"}; adminBoundary=${adminBoundary}`,
|
||
};
|
||
} finally {
|
||
await adminApi?.dispose();
|
||
await financeMgrApi?.dispose();
|
||
await financeApi.dispose();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (shouldRunFlow(selectedFlow, "approval-boundary")) {
|
||
await runStep("审批", "employee/admin/manager", "真实待办申请人和 admin-only 越权拒绝", async () => {
|
||
const employeeApi = await createLiveApiClientWithRetry("employee");
|
||
let managerApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
|
||
let adminApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
|
||
const startDate = futureWeekday(uniqueFutureOffset(5200));
|
||
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, `${trialrunMarkerPrefix} 审批边界 ${marker}`);
|
||
const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>(employeeApi.api, `/api/leave-requests/${leaveId}/submit`, {});
|
||
const taskId = submitResult.currentTaskId;
|
||
if (!taskId) {
|
||
throw new Error(`请假提交后未返回 currentTaskId: ${JSON.stringify(submitResult)}`);
|
||
}
|
||
|
||
const selfApproveResponse = await employeeApi.api.post(`/api/workflow/tasks/${taskId}/approve`, { data: {} });
|
||
const selfBoundary = await expectApiRejected(selfApproveResponse, "申请人本人审批探测");
|
||
|
||
adminApi = await createLiveApiClientWithRetry("admin");
|
||
const adminApproveResponse = await adminApi.api.post(`/api/workflow/tasks/${taskId}/approve`, { data: {} });
|
||
const adminBoundary = await expectApiRejected(adminApproveResponse, "admin-only 审批探测");
|
||
|
||
managerApi = await createLiveApiClientWithRetry("manager");
|
||
await approveWorkflowTaskViaApi(managerApi.api, taskId);
|
||
|
||
await page.goto("/leave-requests");
|
||
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText(/通过|已通过/, { timeout: 30_000 });
|
||
|
||
return {
|
||
businessId: `leave=${leaveId}; task=${taskId}`,
|
||
pcResult: "PASS: 申请人本人和 admin-only 审批接口均被拒绝,经理正常通过并申请人回看已通过",
|
||
detail: `selfBoundary=${selfBoundary}; adminBoundary=${adminBoundary}`,
|
||
};
|
||
} finally {
|
||
await adminApi?.dispose();
|
||
await managerApi?.dispose();
|
||
await employeeApi.dispose();
|
||
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
|
||
}
|
||
});
|
||
}
|
||
|
||
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 {
|
||
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, `${trialrunSafePrefix.toLowerCase()}-${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 样票不可绑定,未产生报销审批待办。" };
|
||
}
|
||
let managerApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | 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, `${trialrunMarkerPrefix} 浏览器请假 ${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(`${trialrunMarkerPrefix} 浏览器采购 ${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(`${trialrunMarkerPrefix} 采购明细 ${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 = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.png`;
|
||
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: "image/png",
|
||
buffer: safePurchaseAttachmentPng,
|
||
});
|
||
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<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: "应付账款", exact: true })).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 = `${trialrunMarkerPrefix} 浏览器付款批次 ${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 返回当前功能未启用,未创建付款批次或执行付款。",
|
||
};
|
||
}
|
||
await page.getByRole("button", { name: "新建批次" }).click();
|
||
const createCard = page.getByTestId("payment-batch-create-panel");
|
||
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("输入报销或差旅待付款单据 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(`${trialrunSafePrefix}-${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(`${trialrunMarkerPrefix} 浏览器复核 ${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);
|
||
});
|