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";