594 行
25 KiB
TypeScript
594 行
25 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-wecom-mobile-rendering-hardening");
|
|
|
|
const IPHONE_WECOM_UA =
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 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 ANDROID_WECOM_UA =
|
|
"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";
|
|
|
|
const VIEWPORT_CASES = [
|
|
{ key: "w320-iphone-wecom", label: "320 iPhone 企业微信", viewport: { width: 320, height: 568 }, userAgent: IPHONE_WECOM_UA },
|
|
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
|
|
{ key: "w375-iphone-wecom", label: "375 iPhone 企业微信", viewport: { width: 375, height: 667 }, userAgent: IPHONE_WECOM_UA },
|
|
{ key: "w390-iphone12-wecom", label: "390 iPhone12 企业微信", viewport: { width: 390, height: 844 }, userAgent: IPHONE_WECOM_UA },
|
|
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
|
|
{ key: "w414-iphone-wecom", label: "414 iPhone 企业微信", viewport: { width: 414, height: 896 }, userAgent: IPHONE_WECOM_UA },
|
|
{ key: "w430-iphone-wecom", label: "430 iPhone 企业微信", viewport: { width: 430, height: 932 }, userAgent: IPHONE_WECOM_UA },
|
|
] as const;
|
|
|
|
const IPHONE_ENGINE_CASE = {
|
|
key: "iphone12-wecom-w390-font-failure",
|
|
label: "390 iPhone12 企业微信 字体失败",
|
|
viewport: { width: 390, height: 844 },
|
|
userAgent: IPHONE_WECOM_UA,
|
|
} as const;
|
|
|
|
const employee = {
|
|
id: "wcm6-employee",
|
|
employeeNo: "SMOKE002",
|
|
name: "OA试运行测试01",
|
|
phone: "13800000000",
|
|
email: "[email protected]",
|
|
departmentId: "wcm6-dept",
|
|
directManagerId: "wcm6-manager",
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes: ["manager", "employee"],
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 60,
|
|
},
|
|
wecomBinding: {
|
|
externalUserId: "oa_smoke_01",
|
|
createdAt: "2026-05-11T08:00:00.000Z",
|
|
},
|
|
};
|
|
|
|
function envelope(data: unknown) {
|
|
return { requestId: "mock-r6-wecom-rendering", 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`);
|
|
}
|
|
|
|
function reimbursementListItem() {
|
|
return {
|
|
id: "wcm6-reimbursement",
|
|
formNumber: "BX20260511006",
|
|
status: "draft",
|
|
reimbursementDate: "2026-05-11",
|
|
totalAmount: "168.30",
|
|
description: "WCM6 移动渲染稳定化验证",
|
|
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-11T08:00:00.000Z",
|
|
updatedAt: "2026-05-11T08:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
async function blockMaterialSymbolsFont(page: Page) {
|
|
await page.route((url) => /material-symbols.*\.(woff2|css)$/i.test(url.pathname), async (route) => {
|
|
await route.abort();
|
|
});
|
|
}
|
|
|
|
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-wcm6-token", expiresIn: 900 })),
|
|
});
|
|
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: "ww-wcm6",
|
|
agentId: "1000002",
|
|
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
|
bindEnabled: true,
|
|
defaultReturnTo: "/mobile",
|
|
status: "ready",
|
|
statusReasons: [],
|
|
});
|
|
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: "wcm6-dept", name: "试运行部" },
|
|
directManager: { id: "wcm6-manager", name: "OA试运行测试02" },
|
|
},
|
|
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
|
|
assignedAssets: [],
|
|
organization: { reportingChain: [], directReports: [] },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/attendance/me/today") {
|
|
await fulfillJson(route, {
|
|
workDate: "2026-05-11",
|
|
needClockIn: true,
|
|
exemptionReason: null,
|
|
item: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/notifications" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
unreadOnly: false,
|
|
unreadCount: 1,
|
|
items: [
|
|
{
|
|
id: "wcm6-notification",
|
|
type: "workflow.task_assigned",
|
|
title: "报销待审批",
|
|
body: "WCM6 移动渲染稳定化待办",
|
|
targetType: "workflow_task",
|
|
targetId: "wcm6-task",
|
|
readAt: null,
|
|
createdAt: "2026-05-11T08:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/tasks/summary" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
scope: "mine",
|
|
pendingTotal: 1,
|
|
overdueTotal: 0,
|
|
earliestPendingAt: "2026-05-11T08:00:00.000Z",
|
|
averagePendingAgeHours: 1,
|
|
byEntityType: [{ key: "reimbursement", pendingTotal: 1, overdueTotal: 0 }],
|
|
byStep: [{ key: "主管审批", pendingTotal: 1, overdueTotal: 0 }],
|
|
byAssigneeRole: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 100,
|
|
total: 1,
|
|
items: [
|
|
{
|
|
id: "wcm6-task",
|
|
instanceId: "wcm6-instance",
|
|
stepId: "wcm6-step",
|
|
stepName: "主管审批",
|
|
assigneeId: employee.id,
|
|
assigneeRole: null,
|
|
status: "pending",
|
|
outcome: null,
|
|
comments: null,
|
|
createdAt: "2026-05-11T08:00:00.000Z",
|
|
completedAt: null,
|
|
workflowInstance: {
|
|
id: "wcm6-instance",
|
|
entityType: "reimbursement",
|
|
entityId: "wcm6-reimbursement",
|
|
currentStatus: "reviewing",
|
|
startedAt: "2026-05-11T08:00:00.000Z",
|
|
completedAt: null,
|
|
},
|
|
businessSummary: {
|
|
reference: "BX20260511006",
|
|
title: "报销待审批",
|
|
applicantName: employee.name,
|
|
amount: "168.30",
|
|
duration: null,
|
|
businessStatus: "reviewing",
|
|
},
|
|
allowedActions: ["approve", "reject", "return"],
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/tasks/wcm6-task" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
task: {
|
|
id: "wcm6-task",
|
|
instanceId: "wcm6-instance",
|
|
stepId: "wcm6-step",
|
|
stepName: "主管审批",
|
|
assigneeId: employee.id,
|
|
assigneeRole: null,
|
|
status: "pending",
|
|
outcome: null,
|
|
comment: null,
|
|
createdAt: "2026-05-11T08:00:00.000Z",
|
|
completedAt: null,
|
|
},
|
|
workflowInstance: {
|
|
id: "wcm6-instance",
|
|
entityType: "reimbursement",
|
|
currentStatus: "reviewing",
|
|
currentStepId: "wcm6-step",
|
|
contextData: { standardViolations: [] },
|
|
startedAt: "2026-05-11T08:00:00.000Z",
|
|
completedAt: null,
|
|
},
|
|
reimbursement: {
|
|
id: "wcm6-reimbursement",
|
|
formNumber: "BX20260511006",
|
|
status: "reviewing",
|
|
reimbursementDate: "2026-05-11",
|
|
totalAmount: "168.30",
|
|
description: "WCM6 移动渲染稳定化验证",
|
|
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
|
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
|
},
|
|
leaveRequest: null,
|
|
purchaseRequest: null,
|
|
travelRequest: null,
|
|
allowedActions: ["approve", "reject", "return"],
|
|
history: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
scope: "own",
|
|
items: [reimbursementListItem()],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/reimbursements/wcm6-reimbursement") {
|
|
await fulfillJson(route, {
|
|
...reimbursementListItem(),
|
|
submitterId: employee.id,
|
|
ownerId: employee.id,
|
|
departmentId: "wcm6-dept",
|
|
department: { id: "wcm6-dept", name: "试运行部" },
|
|
submittedAt: null,
|
|
projectId: null,
|
|
linkedTravelRequestId: null,
|
|
linkedTravelRequest: 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,
|
|
workflowProgress: null,
|
|
approvalHistory: [],
|
|
riskFlags: [],
|
|
items: [
|
|
{
|
|
id: "wcm6-item",
|
|
expenseTypeId: "wcm6-expense-type",
|
|
expenseDate: "2026-05-11",
|
|
amount: "168.30",
|
|
description: "移动渲染验证",
|
|
expenseType: { id: "wcm6-expense-type", code: "office", name: "办公费", category: "office", enabled: true },
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/expense-types") {
|
|
await fulfillJson(route, {
|
|
page: 1,
|
|
pageSize: 20,
|
|
total: 1,
|
|
items: [{ id: "wcm6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }],
|
|
});
|
|
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();
|
|
} else {
|
|
const accountLoginText = page.getByText("使用账号密码登录", { exact: true });
|
|
if (await accountLoginText.isVisible().catch(() => false)) {
|
|
await accountLoginText.click();
|
|
}
|
|
}
|
|
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
|
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 collectRenderingMetrics(page: Page) {
|
|
return page.evaluate(() => {
|
|
const viewportWidth = window.innerWidth;
|
|
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
|
|
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
|
|
const icon = element?.querySelector("svg");
|
|
const label = element?.querySelector(`[data-testid='mobile-nav-${key}-label']`);
|
|
const badge = document.querySelector(`[data-testid='mobile-nav-${key}-badge']`);
|
|
const rect = element?.getBoundingClientRect();
|
|
const iconRect = icon?.getBoundingClientRect();
|
|
const labelRect = label?.getBoundingClientRect();
|
|
const badgeRect = badge?.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;
|
|
return {
|
|
key,
|
|
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
|
width: rect?.width ?? 0,
|
|
height: rect?.height ?? 0,
|
|
hitWithin: Boolean(element && hit instanceof Element && hit.closest(`[data-testid='mobile-nav-${key}']`) === element),
|
|
iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16),
|
|
labelVisible: Boolean(labelRect && labelRect.width >= 20 && labelRect.height > 0),
|
|
badgeOverlapsLabel:
|
|
Boolean(badgeRect && labelRect) &&
|
|
!(badgeRect!.right <= labelRect!.left || badgeRect!.left >= labelRect!.right || badgeRect!.bottom <= labelRect!.top || badgeRect!.top >= labelRect!.bottom),
|
|
badgeOverlapsIcon:
|
|
Boolean(badgeRect && iconRect) &&
|
|
!(badgeRect!.right <= iconRect!.left || badgeRect!.left >= iconRect!.right || badgeRect!.bottom <= iconRect!.top || badgeRect!.top >= iconRect!.bottom),
|
|
};
|
|
});
|
|
const actionItems = Array.from(document.querySelectorAll<HTMLElement>("[data-testid^='mobile-action-'].mobile-icon-action")).map((element) => {
|
|
const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "") ?? "";
|
|
const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon'] svg`);
|
|
const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`);
|
|
const rect = element?.getBoundingClientRect();
|
|
const iconRect = icon?.getBoundingClientRect();
|
|
const labelRect = label?.getBoundingClientRect();
|
|
const labelStyle = label ? window.getComputedStyle(label) : null;
|
|
return {
|
|
key,
|
|
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
|
width: rect?.width ?? 0,
|
|
height: rect?.height ?? 0,
|
|
iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16),
|
|
labelHeight: labelRect?.height ?? 0,
|
|
labelLineHeight: labelStyle ? Number.parseFloat(labelStyle.lineHeight) || 18 : 18,
|
|
labelWhiteSpace: labelStyle?.whiteSpace ?? "",
|
|
overlaps:
|
|
Boolean(labelRect && iconRect) &&
|
|
!(labelRect!.right <= iconRect!.left || labelRect!.left >= iconRect!.right || labelRect!.bottom <= iconRect!.top || labelRect!.top >= iconRect!.bottom),
|
|
};
|
|
});
|
|
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
|
const stickyButtons = [
|
|
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
|
document.querySelector("[data-testid='reimbursement-submit']"),
|
|
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
|
const mobileShell = document.querySelector(".hyb-mobile-shell");
|
|
const avatarSlotRect = document.querySelector("[data-testid='mobile-me-avatar-slot']")?.getBoundingClientRect();
|
|
const profileNameRect = document.querySelector("[data-testid='mobile-me-profile-name']")?.getBoundingClientRect();
|
|
const actionGaps = Array.from(document.querySelectorAll("[data-testid='mobile-me-action-link']")).map((element) => {
|
|
const iconRect = element.querySelector(".mobile-me-action-icon")?.getBoundingClientRect();
|
|
const copyRect = element.querySelector(".mobile-me-action-copy")?.getBoundingClientRect();
|
|
const linkRect = element.getBoundingClientRect();
|
|
return {
|
|
gap: iconRect && copyRect ? copyRect.left - iconRect.right : 0,
|
|
iconWithin:
|
|
Boolean(iconRect) &&
|
|
iconRect!.left >= linkRect.left - 1 &&
|
|
iconRect!.right <= linkRect.right + 1 &&
|
|
iconRect!.top >= linkRect.top - 1 &&
|
|
iconRect!.bottom <= linkRect.bottom + 1,
|
|
copyWithin:
|
|
Boolean(copyRect) &&
|
|
copyRect!.left >= linkRect.left - 1 &&
|
|
copyRect!.right <= linkRect.right + 1 &&
|
|
copyRect!.top >= linkRect.top - 1 &&
|
|
copyRect!.bottom <= linkRect.bottom + 1,
|
|
};
|
|
});
|
|
const moreLinkRects = Array.from(document.querySelectorAll("[data-testid='mobile-more-links'] > *")).map((element) =>
|
|
element.getBoundingClientRect(),
|
|
);
|
|
const mobileSectionHasDecorativeTopLine = Array.from(document.querySelectorAll(".hyb-mobile-shell .section-panel")).some((element) => {
|
|
const before = window.getComputedStyle(element, "::before");
|
|
const opacity = Number.parseFloat(before.opacity || "1");
|
|
const height = Number.parseFloat(before.height || "0");
|
|
return before.display !== "none" && before.visibility !== "hidden" && opacity > 0.01 && height > 0;
|
|
});
|
|
return {
|
|
viewportWidth,
|
|
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
coreMaterialIconCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] .material-symbols-outlined, [data-testid='mobile-action-grid'] .material-symbols-outlined").length,
|
|
coreSvgCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] svg, [data-testid='mobile-action-grid'] svg").length,
|
|
bottomNavVisible: Boolean(document.querySelector("[data-testid='mobile-bottom-nav']")?.getBoundingClientRect().height),
|
|
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
|
navItems,
|
|
actionItems,
|
|
stickyActionsVisible: Boolean(stickyActions?.getBoundingClientRect().height),
|
|
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,
|
|
mobileMe: {
|
|
shellScoped: Boolean(mobileShell),
|
|
profileGap: avatarSlotRect && profileNameRect ? profileNameRect.left - avatarSlotRect.right : null,
|
|
actionGapMin: actionGaps.length ? Math.min(...actionGaps.map((item) => item.gap)) : null,
|
|
actionGapMax: actionGaps.length ? Math.max(...actionGaps.map((item) => item.gap)) : null,
|
|
actionSlotsWithin: actionGaps.every((item) => item.iconWithin && item.copyWithin),
|
|
moreLinksTwoColumns:
|
|
moreLinkRects.length >= 2
|
|
? Math.abs(moreLinkRects[0].top - moreLinkRects[1].top) <= 4 && moreLinkRects[0].right <= moreLinkRects[1].left
|
|
: true,
|
|
hasDecorativeTopLine: mobileSectionHasDecorativeTopLine,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
async function expectRenderingStable(page: Page, label: string, screenshotName: string) {
|
|
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
|
|
const metrics = await collectRenderingMetrics(page);
|
|
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
|
|
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
|
|
expect(metrics.coreMaterialIconCount, `${label} no core material font icons`).toBe(0);
|
|
if (metrics.bottomNavVisible) {
|
|
expect(metrics.coreSvgCount, `${label} core svg icons`).toBeGreaterThanOrEqual(4);
|
|
for (const item of metrics.navItems) {
|
|
expect(item.visible, `${label} ${item.key} nav visible`).toBe(true);
|
|
expect(item.width, `${label} ${item.key} nav width`).toBeGreaterThanOrEqual(44);
|
|
expect(item.height, `${label} ${item.key} nav height`).toBeGreaterThanOrEqual(44);
|
|
expect(item.hitWithin, `${label} ${item.key} nav hit`).toBe(true);
|
|
expect(item.iconVisible, `${label} ${item.key} nav svg visible`).toBe(true);
|
|
expect(item.labelVisible, `${label} ${item.key} nav label visible`).toBe(true);
|
|
expect(item.badgeOverlapsLabel, `${label} ${item.key} badge not over label`).toBe(false);
|
|
expect(item.badgeOverlapsIcon, `${label} ${item.key} badge not over icon`).toBe(false);
|
|
}
|
|
}
|
|
return metrics;
|
|
}
|
|
|
|
async function exerciseMobileRendering(page: Page, key: string, label: string) {
|
|
await mockApis(page);
|
|
await login(page);
|
|
|
|
await page.goto("/mobile");
|
|
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
|
|
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
|
|
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
|
|
const bottomTabLabels = new Set(["首页", "审批", "进度", "报销", "我的"]);
|
|
for (const action of homeMetrics.actionItems) {
|
|
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
|
|
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
|
|
expect(action.width, `${label} ${action.key} action width`).toBeGreaterThanOrEqual(44);
|
|
expect(action.height, `${label} ${action.key} action height`).toBeGreaterThanOrEqual(44);
|
|
expect(action.iconVisible, `${label} ${action.key} action svg visible`).toBe(true);
|
|
expect(action.labelWhiteSpace, `${label} ${action.key} label nowrap`).toBe("nowrap");
|
|
expect(action.labelHeight, `${label} ${action.key} label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
|
|
expect(action.overlaps, `${label} ${action.key} icon/label overlap`).toBe(false);
|
|
}
|
|
|
|
await page.getByTestId("mobile-nav-approvals").click();
|
|
await expect(page).toHaveURL(/\/mobile\/approvals(?:\?.*)?$/);
|
|
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
|
await expectRenderingStable(page, label, `${key}-mobile-approvals`);
|
|
|
|
await page.goto("/mobile/capture");
|
|
await expect(page.getByTestId("mobile-capture-file")).toBeVisible();
|
|
await expectRenderingStable(page, label, `${key}-mobile-capture`);
|
|
|
|
await page.goto("/mobile/me");
|
|
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
|
const meMetrics = await expectRenderingStable(page, label, `${key}-mobile-me`);
|
|
const minSlotGap = meMetrics.viewportWidth >= 390 ? 14 : 12;
|
|
expect(meMetrics.mobileMe.shellScoped, `${label} mobile shell scope`).toBe(true);
|
|
expect(meMetrics.mobileMe.profileGap, `${label} profile avatar/text gap`).not.toBeNull();
|
|
expect(meMetrics.mobileMe.profileGap ?? 0, `${label} profile avatar/text gap min`).toBeGreaterThanOrEqual(minSlotGap);
|
|
expect(meMetrics.mobileMe.profileGap ?? 0, `${label} profile avatar/text gap max`).toBeLessThanOrEqual(20);
|
|
expect(meMetrics.mobileMe.actionGapMin, `${label} action icon/text gap min`).not.toBeNull();
|
|
expect(meMetrics.mobileMe.actionGapMin ?? 0, `${label} action icon/text gap min`).toBeGreaterThanOrEqual(minSlotGap);
|
|
expect(meMetrics.mobileMe.actionGapMax ?? 0, `${label} action icon/text gap max`).toBeLessThanOrEqual(18);
|
|
expect(meMetrics.mobileMe.actionSlotsWithin, `${label} action slots within link`).toBe(true);
|
|
expect(meMetrics.mobileMe.moreLinksTwoColumns, `${label} more links keep two columns`).toBe(true);
|
|
expect(meMetrics.mobileMe.hasDecorativeTopLine, `${label} mobile section top line hidden`).toBe(false);
|
|
|
|
await page.goto("/mobile/reimbursements/wcm6-reimbursement");
|
|
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
|
|
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
|
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
|
|
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
|
|
expect(detailMetrics.stickyActionsVisible, `${label} sticky actions visible`).toBe(true);
|
|
expect(detailMetrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
|
|
expect(Math.round(detailMetrics.stickyButtonMinHeight), `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
|
|
}
|
|
|
|
test.describe("WCM6 企业微信多手机移动渲染稳定化", () => {
|
|
for (const device of VIEWPORT_CASES) {
|
|
test.describe(device.label, () => {
|
|
test.use({
|
|
viewport: device.viewport,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
userAgent: device.userAgent,
|
|
locale: "zh-CN",
|
|
serviceWorkers: "block",
|
|
});
|
|
|
|
test("核心移动入口使用 SVG 图标且跨视口不挤压", async ({ page }) => {
|
|
await exerciseMobileRendering(page, device.key, device.label);
|
|
});
|
|
});
|
|
}
|
|
|
|
test.describe(IPHONE_ENGINE_CASE.label, () => {
|
|
test.use({
|
|
viewport: IPHONE_ENGINE_CASE.viewport,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
userAgent: IPHONE_ENGINE_CASE.userAgent,
|
|
locale: "zh-CN",
|
|
serviceWorkers: "block",
|
|
});
|
|
|
|
test("Material Symbols 字体失败时核心移动入口仍稳定", async ({ page }) => {
|
|
await blockMaterialSymbolsFont(page);
|
|
await exerciseMobileRendering(page, IPHONE_ENGINE_CASE.key, IPHONE_ENGINE_CASE.label);
|
|
});
|
|
});
|
|
});
|