452 行
19 KiB
TypeScript
452 行
19 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
|
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r6-iphone11-wecom-mobile-layout-hotfix");
|
|
const DEVICE_CASES = [
|
|
{
|
|
key: "iphone11-ios14",
|
|
label: "iPhone 11 iOS14",
|
|
viewport: { width: 414, height: 896 },
|
|
userAgent:
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
|
|
},
|
|
{
|
|
key: "redmi-k60",
|
|
label: "Redmi K60",
|
|
viewport: { width: 393, height: 873 },
|
|
userAgent:
|
|
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
|
|
},
|
|
] as const;
|
|
|
|
const EXPECTED_HOME_ACTIONS = [
|
|
{ key: "new-reimbursement", label: "新建报销" },
|
|
{ key: "capture", label: "上传票据" },
|
|
{ key: "leave", label: "请假记录" },
|
|
{ key: "purchase", label: "采购" },
|
|
] as const;
|
|
|
|
const employee = {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
employeeNo: "HYD000006",
|
|
name: "王顺利",
|
|
phone: "13800000000",
|
|
email: "[email protected]",
|
|
departmentId: "dept-1",
|
|
directManagerId: "manager-1",
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes: ["employee", "manager"],
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 50,
|
|
},
|
|
wecomBinding: null,
|
|
};
|
|
|
|
function envelope(data: unknown) {
|
|
return { requestId: "mock-r5-mobile-ux", data };
|
|
}
|
|
|
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(data)),
|
|
});
|
|
}
|
|
|
|
function screenshotPath(fileName: string) {
|
|
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
|
|
}
|
|
|
|
async function mockApis(page: Page) {
|
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const pathName = url.pathname;
|
|
|
|
if (pathName === "/api/auth/login" || pathName === "/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-r5-token", expiresIn: 900 })),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/me") {
|
|
await fulfillJson(route, employee);
|
|
return;
|
|
}
|
|
|
|
if (pathName === `/api/employees/${employee.id}/profile`) {
|
|
await fulfillJson(route, {
|
|
employee: {
|
|
...employee,
|
|
title: "负责人",
|
|
avatarUrl: null,
|
|
department: { id: "dept-1", name: "管理部" },
|
|
directManager: { id: "manager-1", name: "马建勇" },
|
|
},
|
|
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
|
|
assignedAssets: [],
|
|
organization: { reportingChain: [], directReports: [] },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
scope: "own",
|
|
items: [
|
|
{
|
|
id: "r5-reimbursement-1",
|
|
formNumber: null,
|
|
status: "draft",
|
|
reimbursementDate: "2026-05-10",
|
|
totalAmount: "168.30",
|
|
description: "R5 移动交互验收",
|
|
payment: {},
|
|
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
|
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
|
project: null,
|
|
pendingWorkflow: null,
|
|
createdAt: "2026-05-10T08:00:00.000Z",
|
|
updatedAt: "2026-05-10T08:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/reimbursements/r5-reimbursement-1") {
|
|
await fulfillJson(route, {
|
|
id: "r5-reimbursement-1",
|
|
formNumber: null,
|
|
submitterId: employee.id,
|
|
ownerId: employee.id,
|
|
departmentId: "dept-1",
|
|
department: { id: "dept-1", name: "管理部" },
|
|
status: "draft",
|
|
createdAt: "2026-05-10T08:00:00.000Z",
|
|
submittedAt: null,
|
|
reimbursementDate: "2026-05-10",
|
|
totalAmount: "168.30",
|
|
description: "R5 移动交互验收",
|
|
projectId: null,
|
|
linkedTravelRequestId: null,
|
|
linkedTravelRequest: null,
|
|
project: null,
|
|
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
|
|
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
|
|
invoices: [],
|
|
attachments: [],
|
|
budgetHint: null,
|
|
pendingWorkflow: null,
|
|
workflowProgress: null,
|
|
approvalHistory: [],
|
|
riskFlags: [],
|
|
items: [
|
|
{
|
|
id: "item-1",
|
|
expenseTypeId: "expense-type-1",
|
|
expenseDate: "2026-05-10",
|
|
amount: "168.30",
|
|
description: "正式运行口径验证",
|
|
expenseType: { id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true },
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/tasks/summary" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
scope: "mine",
|
|
pendingTotal: 0,
|
|
overdueTotal: 0,
|
|
earliestPendingAt: null,
|
|
averagePendingAgeHours: 0,
|
|
byEntityType: [],
|
|
byStep: [],
|
|
byAssigneeRole: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 100,
|
|
total: 0,
|
|
items: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 0,
|
|
items: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/expense-types") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
items: [{ id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/auth/sso/providers") {
|
|
await fulfillJson(route, []);
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/auth/wecom/config") {
|
|
await fulfillJson(route, {
|
|
enabled: true,
|
|
entryEnabled: true,
|
|
oauthEnabled: true,
|
|
corpId: "wx-test-corp",
|
|
agentId: "1000002",
|
|
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
|
bindEnabled: true,
|
|
defaultReturnTo: "/mobile",
|
|
status: "ready",
|
|
statusReasons: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
await fulfillJson(route, {});
|
|
});
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.goto("/login");
|
|
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
|
|
if (await accountLogin.isVisible().catch(() => false)) {
|
|
await accountLogin.click();
|
|
}
|
|
await page.getByTestId("login-identifier").fill(employee.email);
|
|
await page.getByTestId("login-password").fill("Password1234");
|
|
await page.getByTestId("login-submit").click();
|
|
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
|
}
|
|
|
|
async function collectMobileMetrics(page: Page) {
|
|
return page.evaluate(() => {
|
|
const bottomNav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav");
|
|
const bottomNavRect = bottomNav?.getBoundingClientRect();
|
|
const bottomNavStyle = bottomNav ? window.getComputedStyle(bottomNav) : null;
|
|
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
|
|
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
|
|
const rect = element?.getBoundingClientRect();
|
|
const centerX = rect ? rect.left + rect.width / 2 : 0;
|
|
const centerY = rect ? rect.top + rect.height / 2 : 0;
|
|
const hit = rect ? document.elementFromPoint(centerX, centerY) : null;
|
|
const hitOwner = hit instanceof Element ? hit.closest(`[data-testid='mobile-nav-${key}']`) : null;
|
|
return {
|
|
key,
|
|
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
|
width: rect?.width ?? 0,
|
|
height: rect?.height ?? 0,
|
|
centerX,
|
|
centerY,
|
|
hitWithin: Boolean(element && hitOwner === element),
|
|
hitTarget:
|
|
hit instanceof HTMLElement
|
|
? hit.dataset.testid ?? hit.getAttribute("aria-label") ?? hit.tagName.toLowerCase()
|
|
: null,
|
|
};
|
|
});
|
|
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
|
const stickyRect = stickyActions?.getBoundingClientRect();
|
|
const stickyButtons = [
|
|
document.querySelector("[data-testid='reimbursement-submit']"),
|
|
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
|
const actionItems = Array.from(
|
|
document.querySelectorAll("[data-testid='mobile-action-grid'] > a[data-testid^='mobile-action-']"),
|
|
).map((element) => {
|
|
const testId = element.getAttribute("data-testid") ?? "";
|
|
const key = testId.replace(/^mobile-action-/, "");
|
|
const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`);
|
|
const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon']`);
|
|
const rect = element?.getBoundingClientRect();
|
|
const labelRect = label?.getBoundingClientRect();
|
|
const iconRect = icon?.getBoundingClientRect();
|
|
const style = label ? window.getComputedStyle(label) : null;
|
|
const overlaps = Boolean(
|
|
labelRect &&
|
|
iconRect &&
|
|
!(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom),
|
|
);
|
|
return {
|
|
key,
|
|
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
|
width: rect?.width ?? 0,
|
|
height: rect?.height ?? 0,
|
|
text: label?.textContent?.trim() ?? "",
|
|
labelWidth: labelRect?.width ?? 0,
|
|
labelHeight: labelRect?.height ?? 0,
|
|
labelLineHeight: style ? Number.parseFloat(style.lineHeight) || 18 : 18,
|
|
labelWhiteSpace: style?.whiteSpace ?? "",
|
|
iconWidth: iconRect?.width ?? 0,
|
|
iconHeight: iconRect?.height ?? 0,
|
|
iconSvgTagName: icon?.querySelector("svg")?.tagName.toLowerCase() ?? "",
|
|
overlaps,
|
|
labelWithin:
|
|
Boolean(rect && labelRect) &&
|
|
labelRect!.left >= rect!.left - 1 &&
|
|
labelRect!.right <= rect!.right + 1 &&
|
|
labelRect!.top >= rect!.top - 1 &&
|
|
labelRect!.bottom <= rect!.bottom + 1,
|
|
iconWithin:
|
|
Boolean(rect && iconRect) &&
|
|
iconRect!.left >= rect!.left - 1 &&
|
|
iconRect!.right <= rect!.right + 1 &&
|
|
iconRect!.top >= rect!.top - 1 &&
|
|
iconRect!.bottom <= rect!.bottom + 1,
|
|
};
|
|
}).filter((item) => item.visible);
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
|
bottomNavVisible: Boolean(bottomNavRect && bottomNavRect.height > 0),
|
|
bottomNavFixed: bottomNavStyle?.position === "fixed",
|
|
bottomNavBottomGap: bottomNavRect ? window.innerHeight - bottomNavRect.bottom : null,
|
|
navItems,
|
|
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
|
|
stickyBottomGap: stickyRect ? window.innerHeight - stickyRect.bottom : null,
|
|
stickyButtonsSameRow:
|
|
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
|
|
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
|
|
stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0,
|
|
actionItems,
|
|
text: document.body?.innerText ?? "",
|
|
};
|
|
});
|
|
}
|
|
|
|
test.describe("R5 真机反馈移动交互", () => {
|
|
for (const device of DEVICE_CASES) {
|
|
test.describe(device.label, () => {
|
|
test.use({
|
|
viewport: device.viewport,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
userAgent: device.userAgent,
|
|
locale: "zh-CN",
|
|
serviceWorkers: "block",
|
|
});
|
|
|
|
test("中文头像首字、底部 Tab 固定和详情深层页单一主按钮符合企业微信体验", async ({ page }) => {
|
|
await mockApis(page);
|
|
await login(page);
|
|
|
|
await page.goto("/mobile/me");
|
|
await expect(page.getByText("王", { exact: true })).toBeVisible();
|
|
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-me-avatar`), fullPage: true });
|
|
|
|
await page.goto("/mobile");
|
|
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
|
|
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-home-bottom-nav`), fullPage: true });
|
|
|
|
const homeMetrics = await collectMobileMetrics(page);
|
|
expect(homeMetrics.bottomNavVisible).toBe(true);
|
|
expect(homeMetrics.bottomNavFixed).toBe(true);
|
|
expect(homeMetrics.bottomNavBottomGap).not.toBeNull();
|
|
expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
|
|
expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2);
|
|
expect(homeMetrics.actionItems.map(({ key, text }) => ({ key, label: text }))).toEqual(EXPECTED_HOME_ACTIONS);
|
|
for (const action of homeMetrics.actionItems) {
|
|
expect(action.height, `${device.label} ${action.text} action touch height`).toBeGreaterThanOrEqual(44);
|
|
expect(action.labelWhiteSpace, `${device.label} ${action.text} action label no wrap`).toBe("nowrap");
|
|
expect(action.labelHeight, `${device.label} ${action.text} action label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
|
|
expect(action.labelWidth, `${device.label} ${action.text} action label readable width`).toBeGreaterThanOrEqual(20);
|
|
expect(action.iconWidth, `${device.label} ${action.text} action icon width`).toBeGreaterThanOrEqual(28);
|
|
expect(action.iconHeight, `${device.label} ${action.text} action icon height`).toBeGreaterThanOrEqual(28);
|
|
expect(action.iconSvgTagName, `${device.label} ${action.text} action uses SVG icon`).toBe("svg");
|
|
expect(action.overlaps, `${device.label} ${action.text} action label/icon overlap`).toBe(false);
|
|
expect(action.labelWithin, `${device.label} ${action.text} action label within tile`).toBe(true);
|
|
expect(action.iconWithin, `${device.label} ${action.text} action icon within tile`).toBe(true);
|
|
}
|
|
for (const item of homeMetrics.navItems) {
|
|
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
|
|
expect(item.width, `${device.label} ${item.key} nav item touch width`).toBeGreaterThanOrEqual(44);
|
|
expect(item.height, `${device.label} ${item.key} nav item touch height`).toBeGreaterThanOrEqual(44);
|
|
expect(item.hitWithin, `${device.label} ${item.key} nav item hit target: ${item.hitTarget}`).toBe(true);
|
|
}
|
|
|
|
await page.getByTestId("mobile-nav-approvals").click();
|
|
await expect(page).toHaveURL(/\/mobile\/approvals$/);
|
|
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
|
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-approvals-tab-hit`) });
|
|
const approvalsMetrics = await collectMobileMetrics(page);
|
|
expect(approvalsMetrics.bottomNavVisible).toBe(true);
|
|
expect(approvalsMetrics.bottomNavFixed).toBe(true);
|
|
expect(approvalsMetrics.scrollWidth).toBeLessThanOrEqual(approvalsMetrics.width + 2);
|
|
for (const item of approvalsMetrics.navItems) {
|
|
expect(item.hitWithin, `${device.label} ${item.key} approvals page hit target: ${item.hitTarget}`).toBe(true);
|
|
}
|
|
|
|
await page.goto("/mobile/reimbursements");
|
|
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
|
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursements-tab`), fullPage: true });
|
|
|
|
const listMetrics = await collectMobileMetrics(page);
|
|
expect(listMetrics.bottomNavVisible).toBe(true);
|
|
expect(listMetrics.bottomNavFixed).toBe(true);
|
|
expect(listMetrics.bottomNavBottomGap).not.toBeNull();
|
|
expect(listMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
|
|
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
|
|
|
|
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
|
|
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "返回" })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toHaveCount(0);
|
|
await expect(page.getByTestId("reimbursement-invoice-add")).toBeVisible();
|
|
await expect(page.getByTestId("reimbursement-submit")).toBeVisible();
|
|
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursement-detail-sticky`), fullPage: true });
|
|
|
|
const detailMetrics = await collectMobileMetrics(page);
|
|
expect(detailMetrics.bottomNavVisible).toBe(false);
|
|
expect(detailMetrics.stickyActionsVisible).toBe(true);
|
|
expect(detailMetrics.stickyBottomGap).not.toBeNull();
|
|
expect(detailMetrics.stickyBottomGap!).toBeLessThanOrEqual(2);
|
|
expect(detailMetrics.stickyButtonsSameRow).toBe(true);
|
|
expect(detailMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
|
|
expect(detailMetrics.stickyButtonMinWidth).toBeGreaterThanOrEqual(120);
|
|
expect(detailMetrics.tableCount).toBe(0);
|
|
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
|
|
expect(detailMetrics.text).not.toContain("发票池");
|
|
expect(detailMetrics.text).not.toContain("绑定整单");
|
|
});
|
|
});
|
|
}
|
|
});
|