418 行
13 KiB
TypeScript
418 行
13 KiB
TypeScript
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
|
|
|
|
function envelope(data: unknown) {
|
|
return { requestId: "mock-r101-mobile", data };
|
|
}
|
|
|
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(data)),
|
|
});
|
|
}
|
|
|
|
const me = {
|
|
id: "employee-r101",
|
|
employeeNo: "E101",
|
|
name: "Employee",
|
|
phone: "13800000000",
|
|
email: "[email protected]",
|
|
departmentId: "dept-r101",
|
|
directManagerId: null,
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes: ["employee"],
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 50,
|
|
},
|
|
wecomBinding: null,
|
|
};
|
|
|
|
const leaveType = {
|
|
id: "leave-type-annual",
|
|
code: "annual",
|
|
name: "年假",
|
|
description: null,
|
|
enabled: true,
|
|
requiresAttachment: false,
|
|
attachmentMimeTypes: [],
|
|
sortOrder: 1,
|
|
policy: {
|
|
id: "leave-policy-annual",
|
|
annualQuotaDays: 15,
|
|
enabled: true,
|
|
description: null,
|
|
},
|
|
createdAt: "2026-05-06T00:00:00.000Z",
|
|
updatedAt: "2026-05-06T00:00:00.000Z",
|
|
};
|
|
|
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
|
}
|
|
|
|
function reimbursementItem(status = "draft") {
|
|
return {
|
|
id: "reimbursement-r101-draft",
|
|
formNumber: "BX20260506001",
|
|
status,
|
|
reimbursementDate: "2026-05-06",
|
|
totalAmount: "168.30",
|
|
description: "客户拜访交通费",
|
|
payment: {
|
|
paymentVoucherNo: null,
|
|
paymentDate: null,
|
|
paymentChannel: null,
|
|
paidAt: null,
|
|
paidBy: null,
|
|
},
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
project: null,
|
|
pendingWorkflow: null,
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
};
|
|
}
|
|
|
|
const leaveRequest = {
|
|
id: "leave-r101-draft",
|
|
ownerId: me.id,
|
|
departmentId: me.departmentId,
|
|
status: "draft",
|
|
startDate: "2026-05-08",
|
|
endDate: "2026-05-08",
|
|
startSession: "am",
|
|
endSession: "pm",
|
|
requestedDays: 1,
|
|
reason: "个人事务",
|
|
attachment: null,
|
|
leaveType: {
|
|
id: leaveType.id,
|
|
code: leaveType.code,
|
|
name: leaveType.name,
|
|
requiresAttachment: false,
|
|
attachmentMimeTypes: [],
|
|
},
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
};
|
|
|
|
async function mockMobileApis(page: Page) {
|
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const path = url.pathname;
|
|
|
|
if (path === "/api/auth/sso/providers") {
|
|
await fulfillJson(route, []);
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/auth/wecom/config") {
|
|
await fulfillJson(route, {
|
|
enabled: true,
|
|
entryEnabled: true,
|
|
oauthEnabled: true,
|
|
corpId: "ww-r101",
|
|
agentId: "1000002",
|
|
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
|
bindEnabled: true,
|
|
defaultReturnTo: "/mobile",
|
|
status: "ready",
|
|
statusReasons: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
|
body: JSON.stringify(envelope({ accessToken: "mock-r101-token", expiresIn: 900, mustChangePassword: false })),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/me") {
|
|
await fulfillJson(route, me);
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/system/navigation") {
|
|
await fulfillJson(route, {
|
|
workspace: {
|
|
id: "workspace-r101",
|
|
persisted: true,
|
|
profile: "small_team_internal",
|
|
orgMode: "single_company",
|
|
primaryEntryMode: "wecom",
|
|
notificationStrategy: "in_app",
|
|
devicePriority: "mobile",
|
|
moduleVisibility: {},
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
updatedBy: null,
|
|
},
|
|
sections: {
|
|
main: [
|
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
|
],
|
|
admin: [],
|
|
backup: [],
|
|
},
|
|
recommendedRolePresets: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/portal/home") {
|
|
await fulfillJson(route, {
|
|
workspace: { id: "workspace-r101", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: me.name },
|
|
cards: [
|
|
{ key: "pending_tasks", title: "待处理", value: 0, href: "/workflow/tasks", severity: "normal" },
|
|
{ key: "my_applications", title: "我的申请", value: 2, href: "/reimbursements", severity: "normal" },
|
|
],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
myApplications: [
|
|
{ domain: "reimbursement", count: 1, href: "/reimbursements" },
|
|
{ domain: "leave", count: 1, href: "/leave-requests" },
|
|
],
|
|
riskSummary: null,
|
|
announcements: [],
|
|
quickLinks: [{ key: "capture", title: "上传发票", href: "/mobile/capture" }],
|
|
todayPresence: { date: "2026-05-06", status: "office", label: "在岗", note: null, href: "/team" },
|
|
teamPresence: null,
|
|
weeklyReport: { weekStart: "2026-05-04", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
|
weeklySummary: null,
|
|
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
|
projectSummary: null,
|
|
peopleLifecycle: null,
|
|
adoptionSummary: null,
|
|
recentActivities: [],
|
|
teamStatusBoard: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/attendance/me/today") {
|
|
await fulfillJson(route, {
|
|
workDate: "2026-05-06",
|
|
needClockIn: true,
|
|
exemptionReason: null,
|
|
item: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith("/api/employees/") && path.endsWith("/profile")) {
|
|
await fulfillJson(route, {
|
|
employee: {
|
|
...me,
|
|
avatarUrl: null,
|
|
department: { id: "dept-r101", name: "研发部" },
|
|
manager: null,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursements" && request.method() === "GET") {
|
|
const status = url.searchParams.get("status") ?? "draft";
|
|
const items = status === "draft" ? [reimbursementItem(status)] : [];
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: items.length,
|
|
scope: "own",
|
|
items,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursements" && request.method() === "POST") {
|
|
await fulfillJson(route, {
|
|
id: "reimbursement-r101-created",
|
|
formNumber: null,
|
|
submitterId: me.id,
|
|
ownerId: me.id,
|
|
reimbursementDate: "2026-05-06",
|
|
status: "draft",
|
|
description: null,
|
|
projectId: null,
|
|
project: null,
|
|
totalAmount: "0",
|
|
linkedTravelRequestId: null,
|
|
linkedTravelRequest: null,
|
|
items: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/leave-types") {
|
|
await fulfillJson(route, { items: [leaveType] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/leave-balances") {
|
|
await fulfillJson(route, {
|
|
employeeId: me.id,
|
|
items: [
|
|
{
|
|
leaveType,
|
|
policy: leaveType.policy,
|
|
granted: 15,
|
|
adjusted: 0,
|
|
reserved: 1,
|
|
used: 2,
|
|
available: 12,
|
|
periodStart: "2026-01-01",
|
|
periodEnd: "2026-12-31",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/leave-requests") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
scope: "own",
|
|
items: [leaveRequest],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/notifications") {
|
|
await fulfillJson(route, {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: 5,
|
|
unreadCount: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/workflow/tasks") {
|
|
await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 20 });
|
|
return;
|
|
}
|
|
|
|
await fulfillJson(route, {});
|
|
});
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.goto("/login");
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
|
await page.getByTestId("login-submit").click();
|
|
await expect(page).toHaveURL(/\/portal/);
|
|
}
|
|
|
|
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
|
|
await testInfo.attach(name, {
|
|
body: await page.screenshot({ fullPage: true }),
|
|
contentType: "image/png",
|
|
});
|
|
}
|
|
|
|
async function assertNoMobileDesignRegression(page: Page) {
|
|
await expect(page.getByText("先处理这几件事")).toHaveCount(0);
|
|
await expect(page.getByText("清爽", { exact: true })).toHaveCount(0);
|
|
await expect(page.locator("table:visible")).toHaveCount(0);
|
|
|
|
const darkControls = await page.evaluate(() => {
|
|
function isVisible(element: Element) {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
return Array.from(document.querySelectorAll("a, button, [role='button'], [role='tab']"))
|
|
.filter(isVisible)
|
|
.map((element) => {
|
|
const styles = window.getComputedStyle(element);
|
|
return {
|
|
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 40),
|
|
backgroundColor: styles.backgroundColor,
|
|
color: styles.color,
|
|
};
|
|
})
|
|
.filter((item) => {
|
|
const darkBackgrounds = new Set(["rgb(0, 0, 0)", "rgb(24, 24, 27)", "rgb(15, 23, 42)"]);
|
|
return darkBackgrounds.has(item.backgroundColor) && item.color === "rgb(255, 255, 255)";
|
|
});
|
|
});
|
|
|
|
expect(darkControls).toEqual([]);
|
|
}
|
|
|
|
test.describe("R101 mobile WIP closeout", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await mockMobileApis(page);
|
|
await login(page);
|
|
});
|
|
|
|
test("移动首页和我的页收口", async ({ page }, testInfo) => {
|
|
await page.goto("/mobile");
|
|
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
|
await expect(page.locator("aside")).toHaveCount(0);
|
|
await assertNoMobileDesignRegression(page);
|
|
await attachScreenshot(page, testInfo, "r101-mobile-home.png");
|
|
|
|
await page.goto("/mobile/me");
|
|
await expect(page.getByRole("heading", { name: "我的" })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
|
await expect(page.getByText("低频模块放在这里")).toHaveCount(0);
|
|
await assertNoMobileDesignRegression(page);
|
|
await attachScreenshot(page, testInfo, "r101-mobile-me.png");
|
|
});
|
|
|
|
test("移动报销页使用卡片入口", async ({ page }, testInfo) => {
|
|
await page.goto("/mobile/reimbursements");
|
|
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursements-create")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
|
const card = page.getByTestId("mobile-reimbursement-card").first();
|
|
await expect(card).toBeVisible();
|
|
await expect(card.locator("a").first()).toBeVisible();
|
|
await expect(page.getByRole("link", { name: "继续编辑" })).toHaveCount(0);
|
|
await assertNoMobileDesignRegression(page);
|
|
await attachScreenshot(page, testInfo, "r101-mobile-reimbursements.png");
|
|
});
|
|
|
|
test("移动请假页使用表单和卡片记录", async ({ page }, testInfo) => {
|
|
await page.goto("/mobile/leave");
|
|
await expect(page.getByRole("heading", { name: "请假", exact: true })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-leave-records")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
|
|
|
await page.getByTestId("mobile-leave-view-form").click();
|
|
await expect(page.getByTestId("mobile-leave-summary")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "选择附件" })).toBeVisible();
|
|
await page.getByTestId("mobile-leave-view-records").click();
|
|
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "编辑" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "提交" })).toBeVisible();
|
|
await assertNoMobileDesignRegression(page);
|
|
await attachScreenshot(page, testInfo, "r101-mobile-leave.png");
|
|
});
|
|
});
|