B1-B8 建立项目级流程与QA门禁
这个提交包含在:
@@ -25,6 +25,21 @@ import { toast } from "sonner";
|
||||
|
||||
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
|
||||
|
||||
function batchStatusLabel(value: string) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "reviewed") return "已复核";
|
||||
if (value === "executed") return "已执行";
|
||||
if (value === "failed") return "执行失败";
|
||||
if (value === "cancelled") return "已取消";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function entityTypeLabel(value: string) {
|
||||
if (value === "reimbursement") return "报销";
|
||||
if (value === "travel_order") return "差旅订单";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
return isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
@@ -342,8 +357,8 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
||||
<div>状态:{selectedBatch.status}</div>
|
||||
<div>实体:{selectedBatch.entityType}</div>
|
||||
<div>状态:{batchStatusLabel(selectedBatch.status)}</div>
|
||||
<div>实体:{entityTypeLabel(selectedBatch.entityType)}</div>
|
||||
<div>总金额:{selectedBatch.totalAmount}</div>
|
||||
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
|
||||
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</div>
|
||||
|
||||
@@ -7,6 +7,40 @@ import { resolveReimbursementDisplayCode } from "../../lib/reimbursementDisplay"
|
||||
|
||||
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可用",
|
||||
bound: "已绑定",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const REIMBURSEMENT_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
paid: "已支付",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const TRAVEL_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
submitted: "已提交",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
confirmed: "已确认",
|
||||
paid: "已支付",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function statusLabel(status: string | null | undefined, labels: Record<string, string>) {
|
||||
if (!status) return "未知状态";
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
export type InvoiceBindCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
||||
@@ -73,7 +107,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择票据</option>
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} ({item.status})
|
||||
{`${item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -88,7 +122,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择报销单</option>
|
||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{resolveReimbursementDisplayCode(item)} ({item.status})
|
||||
{resolveReimbursementDisplayCode(item)} ({statusLabel(item.status, REIMBURSEMENT_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -106,7 +140,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择差旅申请</option>
|
||||
{(travelRequestQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.requestNo ?? item.purpose} ({item.status})
|
||||
{item.requestNo ?? item.purpose} ({statusLabel(item.status, TRAVEL_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -121,7 +155,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择差旅订单</option>
|
||||
{bindableTravelOrders.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.externalOrderNo} ({item.status})
|
||||
{item.externalOrderNo} ({statusLabel(item.status, TRAVEL_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -306,7 +306,7 @@ export function InvoiceDetailPanel({
|
||||
onChange={(event) => setEditSetAvailable(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="text-zinc-700">将状态标记为 available(人工确认可用)</span>
|
||||
<span className="text-zinc-700">将状态标记为可用(人工确认可用)</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await expect(page.getByTestId("asset-name")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-category-reset").click();
|
||||
await page.getByTestId("asset-category-code").fill(categoryCode);
|
||||
|
||||
@@ -56,8 +56,14 @@ export async function openInvoicePool(page: Page) {
|
||||
export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: string, timeout = 180_000): Promise<LiveInvoiceOcrResult> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const uploadedInvoiceId = uploadPayload.data?.invoicePoolId ?? null;
|
||||
|
||||
const detailPanel = page.getByTestId("invoice-detail-panel");
|
||||
await expect(detailPanel).toBeVisible({ timeout: 30_000 });
|
||||
@@ -74,7 +80,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
||||
} catch (error) {
|
||||
const detailText = await detailPanel.innerText().catch(() => "");
|
||||
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
|
||||
const invoiceId = extractField(detailText, "票据ID:");
|
||||
const invoiceId = uploadedInvoiceId ?? extractField(detailText, "票据ID:");
|
||||
throw new Error(
|
||||
[
|
||||
"live 腾讯 OCR 等待终态超时",
|
||||
@@ -100,7 +106,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
||||
detailText,
|
||||
bannerText,
|
||||
durationMs: Date.now() - startedAt,
|
||||
invoiceId: extractField(detailText, "票据ID:"),
|
||||
invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"),
|
||||
invoiceCode,
|
||||
invoiceNumber,
|
||||
sellerName: extractField(detailText, "销售方:"),
|
||||
|
||||
@@ -23,7 +23,7 @@ async function createBlankReimbursement(page: import("@playwright/test").Page) {
|
||||
return reimbursementId;
|
||||
}
|
||||
|
||||
test("E2E 轻交互 OCR:报销详情上传后自动可绑定并提交", async ({ page, reimbursementDetail }) => {
|
||||
test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
const reimbursementId = await createBlankReimbursement(page);
|
||||
@@ -34,21 +34,18 @@ test("E2E 轻交互 OCR:报销详情上传后自动可绑定并提交", async
|
||||
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
|
||||
|
||||
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
|
||||
const invoiceFileName = `r73-success-${Date.now()}.png`;
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
|
||||
name: `r73-success-${Date.now()}.png`,
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const text = await page.getByText(/已选 \d+ 张发票/).first().textContent().catch(() => "");
|
||||
return text ?? "";
|
||||
}, { timeout: 90_000 }).toContain("已选 1 张发票");
|
||||
|
||||
await page.getByRole("button", { name: "绑定选中发票" }).click();
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).not.toContainText("暂无关联票据", { timeout: 30_000 });
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).toContainText(invoiceFileName, { timeout: 90_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0);
|
||||
|
||||
await reimbursementDetail.submitReimbursement();
|
||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -71,7 +68,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:ocr_failed", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
|
||||
|
||||
await page.getByTestId("invoice-edit-open").click();
|
||||
@@ -83,7 +80,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
await page.getByTestId("invoice-edit-seller-name").fill("北京悦海堂餐饮管理有限公司");
|
||||
await page.getByTestId("invoice-edit-save").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("北京悦海堂餐饮管理有限公司");
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { expect, test } from "@playwright/test";
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
||||
|
||||
function envelope(data: unknown) {
|
||||
|
||||
@@ -3,14 +3,6 @@ import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
||||
test.setTimeout(240_000);
|
||||
@@ -38,22 +30,29 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
// 2) employee: upload invoice and wait OCR
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const invoiceFileName = `e2e-${Date.now()}.png`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `e2e-${Date.now()}.png`,
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
// 预览(popup 存在即可)
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first();
|
||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceFileName }).first();
|
||||
await expect(invoiceRow).toBeVisible();
|
||||
await invoiceRow.getByRole("button").click();
|
||||
await page.getByTestId("invoice-preview-button").click();
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { devices, expect, test } from "@playwright/test";
|
||||
import { createAuthenticatedApiClient, login, MANAGER } from "./helpers/auth";
|
||||
import { createAuthenticatedApiClient, MANAGER, type E2EUser } from "./helpers/auth";
|
||||
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
|
||||
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow";
|
||||
|
||||
async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/mobile$/);
|
||||
}
|
||||
|
||||
async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, taskId: string) {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -56,7 +64,7 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
entityType: "travel_request",
|
||||
});
|
||||
|
||||
await login(page, MANAGER);
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
@@ -67,12 +75,12 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByText("当前离线")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待同步 1");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过");
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无草稿", { timeout: 30_000 });
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
@@ -92,7 +100,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
entityType: "travel_request",
|
||||
});
|
||||
|
||||
await login(page, MANAGER);
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
@@ -103,7 +111,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待同步 1");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("驳回");
|
||||
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
@@ -30,6 +30,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const title = `Playwright 采购 ${Date.now()}`;
|
||||
let requestNo = "";
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "采购申请")).click();
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
@@ -51,15 +52,15 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
|
||||
|
||||
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]');
|
||||
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("Playwright Laptop");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("100");
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
import { ADMIN, EMPLOYEE, MANAGER, createAuthenticatedApiClient, type E2EUser } from "./helpers/auth";
|
||||
|
||||
type MatrixRow = {
|
||||
Domain: string;
|
||||
Tier: string;
|
||||
PC_Routes: string;
|
||||
Mobile_WeCom_Routes: string;
|
||||
};
|
||||
|
||||
type VisualFinding = {
|
||||
severity: "P1" | "P2";
|
||||
route: string;
|
||||
domain: string;
|
||||
viewport: "pc" | "mobile-wecom";
|
||||
category: string;
|
||||
detail: string;
|
||||
screenshot?: string;
|
||||
};
|
||||
|
||||
type PreparedRoutes = {
|
||||
reimbursementId: string;
|
||||
projectId: string | null;
|
||||
workflowTaskId: string | null;
|
||||
employeeId: string;
|
||||
};
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const MATRIX_PATH = path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/project-wide-visual");
|
||||
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/qa-flow/project-wide-visual");
|
||||
const WECOM_UA =
|
||||
"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 UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
||||
|
||||
const findings: VisualFinding[] = [];
|
||||
|
||||
function outputPath(dir: string, fileName: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, fileName);
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string) {
|
||||
const cells: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (quoted && char === "\"" && next === "\"") {
|
||||
current += "\"";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === ",") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseMatrix() {
|
||||
const lines = readFileSync(MATRIX_PATH, "utf8").split(/\r?\n/).filter((line) => line.trim());
|
||||
const header = parseCsvLine(lines[0] ?? "");
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = parseCsvLine(line);
|
||||
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])) as MatrixRow;
|
||||
});
|
||||
}
|
||||
|
||||
function splitRoutes(value: string) {
|
||||
return value.split("|").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function sanitizeRoute(route: string) {
|
||||
return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root";
|
||||
}
|
||||
|
||||
function addFinding(finding: VisualFinding) {
|
||||
findings.push({
|
||||
...finding,
|
||||
detail: finding.detail.replace(UUID_RE, "[uuid]"),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceDynamicRoute(route: string, prepared: PreparedRoutes) {
|
||||
if (route.includes(":reimbursementId")) return route.replace(":reimbursementId", prepared.reimbursementId);
|
||||
if (route.includes("/reimbursements/:id")) return route.replace(":id", prepared.reimbursementId);
|
||||
if (route.includes(":projectId")) return prepared.projectId ? route.replace(":projectId", prepared.projectId) : null;
|
||||
if (route.includes(":taskId")) return prepared.workflowTaskId ? route.replace(":taskId", prepared.workflowTaskId) : null;
|
||||
if (route.includes(":employeeId")) return route.replace(":employeeId", prepared.employeeId);
|
||||
return route;
|
||||
}
|
||||
|
||||
async function loginForVisual(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function newWecomContext(browser: Browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: WECOM_UA,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function prepareRoutes() {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const managerClient = await createAuthenticatedApiClient(MANAGER);
|
||||
const adminClient = await createAuthenticatedApiClient(ADMIN);
|
||||
try {
|
||||
const created = await employeeClient.api.post("/api/reimbursements", {
|
||||
data: { description: "项目级视觉巡检草稿" },
|
||||
});
|
||||
const createdJson = await created.json() as { data?: { id?: string } };
|
||||
if (!created.ok() || !createdJson.data?.id) {
|
||||
throw new Error(`创建视觉巡检报销草稿失败:HTTP ${created.status()}`);
|
||||
}
|
||||
|
||||
const projectsResponse = await adminClient.api.get("/api/projects?page=1&pageSize=1");
|
||||
const projectsJson = await projectsResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null;
|
||||
const projectId = projectsResponse.ok() ? projectsJson?.data?.items?.[0]?.id ?? null : null;
|
||||
|
||||
const tasksResponse = await managerClient.api.get("/api/workflow/tasks?status=pending&page=1&pageSize=1");
|
||||
const tasksJson = await tasksResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null;
|
||||
const workflowTaskId = tasksResponse.ok() ? tasksJson?.data?.items?.[0]?.id ?? null : null;
|
||||
|
||||
return {
|
||||
reimbursementId: createdJson.data.id,
|
||||
projectId,
|
||||
workflowTaskId,
|
||||
employeeId: employeeClient.me.id,
|
||||
};
|
||||
} finally {
|
||||
await Promise.all([employeeClient.dispose(), managerClient.dispose(), adminClient.dispose()]);
|
||||
}
|
||||
}
|
||||
|
||||
function userForRoute(route: string): E2EUser {
|
||||
if (route.startsWith("/mobile/approvals") || route.startsWith("/workflow/tasks")) return MANAGER;
|
||||
if (
|
||||
route.startsWith("/system") ||
|
||||
route.startsWith("/people") ||
|
||||
route.startsWith("/organization") ||
|
||||
route.startsWith("/audit-logs") ||
|
||||
route.startsWith("/payroll") ||
|
||||
route.startsWith("/performance")
|
||||
) {
|
||||
return ADMIN;
|
||||
}
|
||||
return EMPLOYEE;
|
||||
}
|
||||
|
||||
async function auditPage(page: Page, input: {
|
||||
route: string;
|
||||
domain: string;
|
||||
viewport: "pc" | "mobile-wecom";
|
||||
}) {
|
||||
const responses: Array<{ status: number; url: string }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 500) {
|
||||
responses.push({ status: response.status(), url: response.url() });
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource: the server responded with a status of 4\d\d/i.test(text)) {
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(input.route);
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const screenshot = outputPath(
|
||||
SCREENSHOT_DIR,
|
||||
`${input.viewport}-${sanitizeRoute(input.route)}.png`,
|
||||
);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const text = document.body?.innerText ?? "";
|
||||
const root = document.documentElement;
|
||||
const width = window.innerWidth;
|
||||
const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
|
||||
const visibleTables = Array.from(document.querySelectorAll("table")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
||||
}).length;
|
||||
const sidebarLike = Array.from(document.querySelectorAll("aside, nav")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const textContent = element.textContent ?? "";
|
||||
return rect.width > 160 && textContent.includes("备用模块");
|
||||
}).length;
|
||||
const clipped = Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
const style = getComputedStyle(element);
|
||||
const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY);
|
||||
return overflowHidden && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
testid: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60),
|
||||
}));
|
||||
return { text, width, scrollWidth, visibleTables, sidebarLike, clipped };
|
||||
});
|
||||
|
||||
if (responses.length) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "http",
|
||||
detail: JSON.stringify(responses.slice(0, 5)),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (consoleErrors.length) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "console",
|
||||
detail: consoleErrors.slice(0, 5).join("\n"),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.scrollWidth > metrics.width + 2) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "responsive",
|
||||
detail: `viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.viewport === "mobile-wecom") {
|
||||
if (metrics.visibleTables > 0) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "mobile-table",
|
||||
detail: `移动页存在可见 table:${metrics.visibleTables}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (metrics.sidebarLike > 0) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "mobile-pc-shell",
|
||||
detail: "企业微信移动端出现 PC 导航壳。",
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rawCodePatterns = [/\bdraft\b/i, /\bbound\b/i, /business_client_meal/i, /expense-type-[a-z0-9-]+/i];
|
||||
const shouldApplyOrdinaryUserCopyGate = (
|
||||
input.route.startsWith("/reimbursements") ||
|
||||
input.route.startsWith("/mobile/reimbursements") ||
|
||||
input.route.startsWith("/mobile/capture")
|
||||
);
|
||||
const bannedTerms = ["绑定整单", "不绑定明细", "发票池"];
|
||||
if (shouldApplyOrdinaryUserCopyGate && (rawCodePatterns.some((pattern) => pattern.test(metrics.text)) || bannedTerms.some((term) => metrics.text.includes(term)))) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "copy",
|
||||
detail: "普通用户页面出现原始码值或内部术语。",
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.clipped.length) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "clipped",
|
||||
detail: JSON.stringify(metrics.clipped),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport: "pc" | "mobile-wecom") {
|
||||
const seen = new Set<string>();
|
||||
const plans: Array<{ route: string; domain: string }> = [];
|
||||
|
||||
for (const row of matrix) {
|
||||
const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes);
|
||||
for (const token of tokens) {
|
||||
const route = replaceDynamicRoute(token, prepared);
|
||||
if (!route || seen.has(route)) continue;
|
||||
seen.add(route);
|
||||
plans.push({ route, domain: row.Domain });
|
||||
}
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
async function auditPlans(browser: Browser, plans: Array<{ route: string; domain: string }>, viewport: "pc" | "mobile-wecom") {
|
||||
const plansByUser = new Map<string, { user: E2EUser; plans: Array<{ route: string; domain: string }> }>();
|
||||
for (const plan of plans) {
|
||||
const user = userForRoute(plan.route);
|
||||
const entry = plansByUser.get(user.identifier) ?? { user, plans: [] };
|
||||
entry.plans.push(plan);
|
||||
plansByUser.set(user.identifier, entry);
|
||||
}
|
||||
|
||||
for (const { user, plans: userPlans } of plansByUser.values()) {
|
||||
const context = viewport === "mobile-wecom"
|
||||
? await newWecomContext(browser)
|
||||
: await browser.newContext({ viewport: { width: 1366, height: 900 }, serviceWorkers: "block" });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await loginForVisual(page, user);
|
||||
for (const plan of userPlans) {
|
||||
await auditPage(page, { ...plan, viewport });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("项目级全页面视觉巡检", () => {
|
||||
test("PC 与企业微信移动端没有已知视觉和文案回归", async ({ browser }) => {
|
||||
const prepared = await prepareRoutes();
|
||||
const matrix = parseMatrix();
|
||||
const pcPlans = buildRoutePlans(matrix, prepared, "pc");
|
||||
const mobilePlans = buildRoutePlans(matrix, prepared, "mobile-wecom");
|
||||
|
||||
await auditPlans(browser, pcPlans, "pc");
|
||||
await auditPlans(browser, mobilePlans, "mobile-wecom");
|
||||
|
||||
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
|
||||
writeFileSync(reportPath, JSON.stringify({
|
||||
checkedAt: new Date().toISOString(),
|
||||
pcRoutes: pcPlans.map((item) => item.route),
|
||||
mobileRoutes: mobilePlans.map((item) => item.route),
|
||||
findings,
|
||||
}, null, 2), "utf8");
|
||||
|
||||
expect(findings.filter((finding) => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -331,7 +331,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("详情页发票默认绑定唯一明细", async ({ page }) => {
|
||||
test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
@@ -341,12 +341,24 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
|
||||
await page.getByTestId("reimbursement-invoice-add").click();
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveValue(itemId);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细");
|
||||
await expect(page.getByText("绑定整单")).toHaveCount(0);
|
||||
await expect(page.getByText("不绑定明细")).toHaveCount(0);
|
||||
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0);
|
||||
const uploadInput = page.getByTestId("reimbursement-invoice-upload-file");
|
||||
await expect(uploadInput).toHaveAttribute("multiple", "");
|
||||
await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible();
|
||||
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await expect(page.getByText("已选 1 张发票")).toBeVisible();
|
||||
|
||||
const bindResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok();
|
||||
});
|
||||
await page.getByRole("button", { name: "添加选中发票" }).click();
|
||||
await bindResponsePromise;
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { devices, expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function browserAgnosticDevice(name: keyof typeof devices) {
|
||||
const device = devices[name];
|
||||
return {
|
||||
@@ -408,8 +410,9 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
|
||||
await page.getByTestId("mobile-nav-capture").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/capture/);
|
||||
await expect(page.getByText("拍照或选择文件", { exact: true })).toBeVisible();
|
||||
await page.getByTestId("mobile-capture-file").setInputFiles({
|
||||
await expect(page.getByRole("heading", { name: "票据上传" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /选择 PDF/ })).toBeVisible();
|
||||
await page.getByTestId("mobile-capture-pdf-file").setInputFiles({
|
||||
name: "r89-invoice.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\n% R89"),
|
||||
@@ -425,22 +428,18 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("R89 小屏桌面菜单兜底", () => {
|
||||
test.describe("R89 小屏移动路由兜底", () => {
|
||||
test.use(browserAgnosticDevice("Pixel 5"));
|
||||
|
||||
test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => {
|
||||
test("非移动路由在小屏场景会转到移动通知页", async ({ page }) => {
|
||||
await mockR89Apis(page);
|
||||
|
||||
await loginByPassword(page);
|
||||
await page.getByTestId("mobile-nav-me").click();
|
||||
await page.getByRole("link", { name: /通知中心/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/notifications/);
|
||||
await page.getByRole("button", { name: "打开菜单" }).click();
|
||||
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Portal" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "关闭菜单" }).click();
|
||||
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden();
|
||||
await expect(page).toHaveURL(/\/mobile\/notifications/);
|
||||
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "打开菜单" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import {
|
||||
ADMIN,
|
||||
apiGetData,
|
||||
createAuthenticatedApiClient,
|
||||
EMPLOYEE,
|
||||
@@ -12,12 +13,37 @@ import {
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
type FeatureFlagItem = {
|
||||
flagKey: string;
|
||||
overrideMode: "inherit" | "force_on" | "force_off";
|
||||
rolloutType: "global" | "department_allowlist";
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
version: number;
|
||||
effectiveEnabled: boolean;
|
||||
};
|
||||
|
||||
async function ensureFeatureFlagOn(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], flagKey: string) {
|
||||
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
|
||||
const item = list.items.find((flag) => flag.flagKey === flagKey);
|
||||
if (!item) {
|
||||
throw new Error(`Missing feature flag: ${flagKey}`);
|
||||
}
|
||||
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
|
||||
return;
|
||||
}
|
||||
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
|
||||
data: {
|
||||
overrideMode: "force_on",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "playwright reimbursement payment batch flow",
|
||||
expectedVersion: item.version,
|
||||
},
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
|
||||
@@ -27,11 +53,15 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const adminApi = await createAuthenticatedApiClient(ADMIN);
|
||||
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
|
||||
const paymentDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1");
|
||||
await ensureFeatureFlagOn(adminApi.api, "erp_sync");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await (await openNavLink(page, "我的报销")).click();
|
||||
@@ -53,17 +83,24 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `r78-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
@@ -125,7 +162,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
|
||||
await expect(batchDetailCard).toBeVisible();
|
||||
await expect(batchDetailCard).toContainText("状态:draft");
|
||||
await expect(batchDetailCard).toContainText("状态:草稿");
|
||||
await expect(batchDetailCard).toContainText("复核进度:0/1");
|
||||
|
||||
await logout(page, FINANCE.name);
|
||||
@@ -149,7 +186,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await expect(executedBatchDetailCard).toBeVisible();
|
||||
await expect(executedBatchDetailCard).toContainText("复核进度:1/1");
|
||||
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click();
|
||||
await expect(executedBatchDetailCard).toContainText("状态:executed", { timeout: 30_000 });
|
||||
await expect(executedBatchDetailCard).toContainText("状态:已执行", { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
@@ -185,6 +222,6 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
expect(erpPreview.exportState.lastOutboxId).toBeTruthy();
|
||||
expect(erpPreview.exportState.lastOutboxStatus).toBe("pending");
|
||||
} finally {
|
||||
await Promise.all([employeeApi.dispose(), financeMgrApi.dispose()]);
|
||||
await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,14 +2,6 @@ import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -34,18 +26,25 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `progress-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
await page.getByTestId("invoice-bind-submit").click();
|
||||
|
||||
@@ -2,16 +2,8 @@ import { expect, test } from "@playwright/test";
|
||||
import { createAuthenticatedApiClient, EMPLOYEE, FINANCE_MGR, login, logout, MANAGER } from "./helpers/auth";
|
||||
import { createAndSubmitTravelRequestViaUi, importTravelPaymentSeed } from "./helpers/travel";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function extractInvoiceCode(text: string) {
|
||||
const match = text.match(/发票号:([A-Z0-9]+)-/);
|
||||
const match = text.match(/发票(?:号|号码):([A-Z0-9]+)-/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse invoice code from text: ${text}`);
|
||||
}
|
||||
@@ -61,16 +53,25 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
||||
|
||||
await page.goto("/invoice-pool");
|
||||
const invoiceFileName = `travel-payment-${Date.now()}.png`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(`TRAVEL_PAYMENT_${Date.now()}`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
const invoiceCode = extractInvoiceCode(invoiceDetailText);
|
||||
|
||||
await page.reload();
|
||||
|
||||
@@ -33,9 +33,9 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
}),
|
||||
@@ -46,6 +46,7 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "wecom-fallback-token",
|
||||
@@ -56,6 +57,20 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "wecom-fallback-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -99,9 +114,9 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
primaryEntryMode: "wecom",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
devicePriority: "mobile",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
@@ -162,10 +177,13 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fportal&wecom=unbound");
|
||||
await page.goto("/login?redirect=%2Fmobile&wecom=unbound");
|
||||
await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定");
|
||||
if (await page.getByTestId("login-identifier").count() === 0) {
|
||||
await page.getByRole("button", { name: "使用账号密码登录" }).click();
|
||||
}
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("ResetA1abc123def456");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page).toHaveURL(/\/mobile/);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户