R31-1 R31-4: 收口详情页工作台化与 E2E 契约

这个提交包含在:
2026-03-24 08:58:36 +08:00
父节点 994d98e74c
当前提交 279be83931
共修改 17 个文件,包含 906 行新增和 455 行删除
+7
查看文件
@@ -34,6 +34,12 @@ export const FINANCE_MGR: E2EUser = {
name: "Finance Manager",
};
export const ADMIN: E2EUser = {
identifier: "[email protected]",
password: "AdminPassword1234",
name: "Admin",
};
export type MeResponse = {
id: string;
employeeNo: string;
@@ -80,6 +86,7 @@ export async function login(page: Page, user: E2EUser) {
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
await page.getByTestId(TEST_IDS.login.submit).click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible();
}
export async function logout(page: Page, userDisplayName?: string) {
@@ -0,0 +1,23 @@
import { test as base } from "@playwright/test";
import { AppShell } from "../pageObjects/AppShell";
import { ReimbursementDetailPageObject } from "../pageObjects/ReimbursementDetailPage";
import { WorkflowTaskDetailPageObject } from "../pageObjects/WorkflowTaskDetailPage";
export const test = base.extend<{
shell: AppShell;
reimbursementDetail: ReimbursementDetailPageObject;
workflowDetail: WorkflowTaskDetailPageObject;
}>({
shell: async ({ page }, use) => {
await use(new AppShell(page));
},
reimbursementDetail: async ({ page }, use) => {
await use(new ReimbursementDetailPageObject(page));
},
workflowDetail: async ({ page }, use) => {
await use(new WorkflowTaskDetailPageObject(page));
},
});
export { expect } from "@playwright/test";
@@ -0,0 +1,45 @@
import { expect, type Page } from "@playwright/test";
import { openBackupModulesIfNeeded, openNavLink, openProfileSettings } from "../navigation";
import { TEST_IDS } from "../testIds";
export class AppShell {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get sidebar() {
return this.page.locator("nav").first();
}
get topbarProfileTrigger() {
return this.page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
}
async navigate(name: string | RegExp) {
return openNavLink(this.page, name);
}
async openBackupModules() {
return openBackupModulesIfNeeded(this.page);
}
async openProfile(userDisplayName?: string) {
return openProfileSettings(this.page, userDisplayName);
}
async assertMainNavVisible() {
await expect(this.page.getByRole("link", { name: "工作台" }).or(this.page.getByRole("link", { name: "Portal" })).first()).toBeVisible();
await expect(this.page.getByRole("link", { name: /项目/ }).first()).toBeVisible();
}
async assertBackupModulesCollapsed() {
const summary = this.page.locator("summary").filter({ hasText: "备用模块" }).first();
if (await summary.count()) {
const details = summary.locator("xpath=ancestor::details[1]");
await expect(details).not.toHaveAttribute("open", "");
}
}
}
@@ -0,0 +1,66 @@
import { expect, type Page } from "@playwright/test";
import { TEST_IDS } from "../testIds";
export class ReimbursementDetailPageObject {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get overview() {
return this.page.getByTestId(TEST_IDS.reimbursement.overview);
}
get summary() {
return this.page.getByTestId(TEST_IDS.reimbursement.summary);
}
get itemsTable() {
return this.page.getByTestId(TEST_IDS.reimbursement.items);
}
get addItemBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.itemAdd);
}
get budgetHint() {
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
}
get erpExport() {
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
}
get submitBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.submit);
}
get withdrawBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.withdraw);
}
get payBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.pay);
}
async assertLoaded() {
await expect(this.overview).toBeVisible({ timeout: 15_000 });
await expect(this.summary).toBeVisible({ timeout: 15_000 });
}
async assertStatus(statusText: string) {
await expect(this.summary).toContainText(statusText);
}
// 当前 seed 保证 precheck 不触发 warn/manual_review;若 seed 变更,此处需相应调整。
async submitReimbursement(acceptConfirm = true) {
const dialogPromise = acceptConfirm
? this.page.waitForEvent("dialog", { timeout: 2_000 }).then((dialog) => dialog.accept()).catch(() => undefined)
: Promise.resolve();
await this.submitBtn.click();
await dialogPromise;
}
}
@@ -0,0 +1,66 @@
import { expect, type Page } from "@playwright/test";
import { TEST_IDS } from "../testIds";
export class WorkflowTaskDetailPageObject {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get overview() {
return this.page.getByTestId(TEST_IDS.workflow.overview);
}
get summary() {
return this.page.getByTestId(TEST_IDS.workflow.summary);
}
get approveBtn() {
return this.page.getByTestId(TEST_IDS.workflow.approve);
}
get rejectCommentInput() {
return this.page.getByTestId(TEST_IDS.workflow.rejectComment);
}
get rejectBtn() {
return this.page.getByTestId(TEST_IDS.workflow.reject);
}
get returnCommentInput() {
return this.page.getByTestId(TEST_IDS.workflow.returnComment);
}
get returnBtn() {
return this.page.getByTestId(TEST_IDS.workflow.returnBtn);
}
get actionError() {
return this.page.getByTestId(TEST_IDS.workflow.actionError);
}
get transferApproverSelect() {
return this.page.getByTestId(TEST_IDS.workflow.transferApprover);
}
async assertLoaded() {
await expect(this.overview).toBeVisible({ timeout: 15_000 });
await expect(this.summary).toBeVisible({ timeout: 15_000 });
}
async approve() {
await this.approveBtn.click();
}
async reject(comment: string) {
await this.rejectCommentInput.fill(comment);
await this.rejectBtn.click();
}
async returnTask(comment: string) {
await this.returnCommentInput.fill(comment);
await this.returnBtn.click();
}
}
+1 -20
查看文件
@@ -1,20 +1 @@
export const TEST_IDS = {
login: {
identifier: "login-identifier",
password: "login-password",
submit: "login-submit",
},
topbar: {
profileTrigger: "topbar-profile-trigger",
},
workflow: {
tasksTable: "workflow-tasks-table",
taskRow: "workflow-task-row",
detailLink: "workflow-task-detail-link",
summary: "workflow-task-summary",
approve: "workflow-task-approve",
},
audit: {
export: "audit-export",
},
} as const;
export { TEST_IDS } from "../../../src/lib/testIds";
+33 -32
查看文件
@@ -1,8 +1,8 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
import type { Locator, Page } from "@playwright/test";
function formatDate(offsetDays: number) {
const value = new Date();
@@ -69,7 +69,7 @@ async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedA
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
}
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
const firstDate = formatDate(baseOffset);
@@ -77,42 +77,43 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
await (await openNavLink(page, "请假")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
await submitLeave(employeeClient.api, firstLeaveId);
await page.goto("/leave-requests");
const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
await submitLeave(employeeClient.api, firstLeaveId);
await page.goto("/leave-requests");
const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
await withdrawLeave(employeeClient.api, firstLeaveId);
await page.goto("/leave-requests");
await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
await withdrawLeave(employeeClient.api, firstLeaveId);
await page.goto("/leave-requests");
await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
await page.goto("/leave-requests");
const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
await page.goto("/leave-requests");
const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
await logout(page, EMPLOYEE.name);
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
await expect(taskRow).toBeVisible({ timeout: 30_000 });
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await page.getByTestId(TEST_IDS.workflow.approve).click();
await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
await expect(taskRow).toBeVisible({ timeout: 30_000 });
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await logout(page, MANAGER.name);
await logout(page, MANAGER.name);
await login(page, EMPLOYEE);
await page.goto("/leave-requests");
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
await login(page, EMPLOYEE);
await page.goto("/leave-requests");
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
} finally {
await employeeClient.dispose();
}
+23 -36
查看文件
@@ -1,24 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
const EMPLOYEE = { identifier: "[email protected]", password: "EmployeePassword1234", name: "Employee" };
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
const FINANCE = { identifier: "[email protected]", password: "FinancePassword1234", name: "Finance" };
async function login(page: Page, user: { identifier: string; password: string }) {
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(/\/portal/);
}
async function logout(page: Page, userDisplayName: string) {
await page.getByRole("button", { name: userDisplayName }).click();
await page.getByRole("menuitem", { name: "退出登录" }).click();
await expect(page).toHaveURL(/\/login/);
}
import { TEST_IDS } from "./helpers/testIds";
function extractId(text: string, label: string) {
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
@@ -28,11 +11,13 @@ function extractId(text: string, label: string) {
return match[1];
}
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page }) => {
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
// 1) employee: create reimbursement
await login(page, EMPLOYEE);
await page.locator("nav").getByRole("link", { name: "我的报销", exact: true }).click();
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();
@@ -44,9 +29,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
const reimbursementSummaryText = await page.getByTestId("reimbursement-summary").innerText();
await reimbursementDetail.assertLoaded();
// 2) employee: upload invoice and wait OCR
await (await openNavLink(page, "票据中心")).click();
@@ -82,7 +65,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 4) submit reimbursement
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
// 5) manager approve
@@ -90,28 +74,31 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(managerTaskRow).toBeVisible();
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await logout(page, MANAGER.name);
// 6) finance approve + pay
await login(page, FINANCE);
await (await openNavLink(page, "待我处理")).click();
const financeTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId("workflow-task-detail-link").click();
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
await page.getByTestId("reimbursement-pay").click();
await reimbursementDetail.payBtn.click();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await logout(page, FINANCE.name);
@@ -14,15 +14,22 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
const detail = await new Promise<unknown>((resolve, reject) => {
const query = store.get(currentTaskId);
query.onsuccess = () => resolve(query.result ?? null);
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
});
try {
if (!db.objectStoreNames.contains("workflow_task_cache")) {
return false;
}
db.close();
return Boolean(detail);
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
const detail = await new Promise<unknown>((resolve, reject) => {
const query = store.get(currentTaskId);
query.onsuccess = () => resolve(query.result ?? null);
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
});
return Boolean(detail);
} finally {
db.close();
}
}, taskId);
},
{ timeout: 15_000 },
+14 -16
查看文件
@@ -1,37 +1,35 @@
import { expect, test } from "@playwright/test";
import { login } from "./helpers/auth";
import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
import { test, expect } from "./helpers/fixtures";
import { ADMIN, login } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
const ADMIN = {
identifier: "[email protected]",
password: "AdminPassword1234",
name: "Admin",
};
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
await login(page, ADMIN);
await page.goto("/portal");
await assertBackupModulesCollapsed(page);
await expect(shell.topbarProfileTrigger).toBeVisible();
await shell.assertBackupModulesCollapsed();
await page.goto("/system/workspace");
await (await shell.navigate("工作区设置")).click();
await expect(page).toHaveURL(/\/system\/workspace/);
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
await expect(page.getByText("产品模式")).toBeVisible();
await page.goto("/system/settings");
await (await shell.navigate("系统设置")).click();
await expect(page).toHaveURL(/\/system\/settings/);
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
await page.goto("/system/feature-flags");
await (await shell.navigate("功能开关")).click();
await expect(page).toHaveURL(/\/system\/feature-flags/);
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
await page.goto("/system/workflow-definitions");
await expect(page).toHaveURL(/\/system\/workflow-definitions/);
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
await expect(page.getByText("版本列表")).toBeVisible();
await page.goto("/audit-logs");
await (await shell.navigate("审计日志")).click();
await expect(page).toHaveURL(/\/audit-logs/);
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
});
+63
查看文件
@@ -0,0 +1,63 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
function formatWeekStart(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待办", async ({ page }) => {
const uniqueToken = `Playwright 周报联动 ${Date.now()}`;
const weekStart = formatWeekStart(35 + Number(String(Date.now()).slice(-1)));
await login(page, EMPLOYEE);
await (await openNavLink(page, "周报")).click();
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
const weeklyLoadResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok();
});
await page.getByLabel("周报归档周").fill(weekStart);
await weeklyLoadResponse;
await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`);
await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`);
await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`);
const saveResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.save).click();
await saveResponse;
await expect(page.getByText("周报已保存")).toBeVisible();
const createTodoResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.submit).click();
await submitResponse;
await expect(page.getByText("周报已提交")).toBeVisible();
await expect(page.getByText("已提交", { exact: true })).toBeVisible();
await (await openNavLink(page, "待办")).click();
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await logout(page, EMPLOYEE.name);
});