import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/07-18-small-team-internal-readiness"); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; function envelope(data: unknown) { return { requestId: "mock-internal-readiness-ui", 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); } function buildMe() { return { id: "employee-internal-readiness", employeeNo: "SYN005", name: "合成员工", phone: "13800000000", email: "employee@example.test", departmentId: "department-internal-readiness", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 40, }, wecomBinding: null, }; } function archiveItem() { return { id: "archive-internal-readiness", spNoPrefix: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE-20260718", templateIdPrefix: "SYNTHETIC-TEMPLATE", templateType: "leave", spName: "合成请假归档记录(用于验证窄屏长文案安全换行)", normalizedStatus: "approved", applicant: { id: "employee-internal-readiness", employeeNo: "SYN005", name: "合成长姓名用于验证移动端不会横向裁切", }, appliedAt: "2026-07-17T01:00:00.000Z", submittedAt: "2026-07-17T01:05:00.000Z", completedAt: "2026-07-17T02:00:00.000Z", statusObservedAt: "2026-07-17T02:00:00.000Z", lastSource: "callback", lastCallbackAt: "2026-07-17T02:00:00.000Z", lastCompensationAt: null, readabilityStatus: "readable", singleTapApproveRisk: false, templateAdjustmentRequired: false, participantCount: 2, actionCount: 1, fieldCount: 2, findingCount: 0, openFindingCount: 0, }; } function archiveDetail() { return { ...archiveItem(), participants: [ { id: "participant-internal-readiness", role: "applicant", nodeIndex: 0, employee: archiveItem().applicant, isCurrent: false, mappingStatus: "matched", }, ], actions: [ { id: "action-internal-readiness", actionType: "approve", operator: { id: "manager-internal-readiness", employeeNo: "SYN006", name: "合成审批人" }, actionTime: "2026-07-17T02:00:00.000Z", nodeIndex: 1, source: "callback", }, ], fields: [ { id: "field-internal-readiness-1", fieldKey: "long_business_field", fieldIndex: 0, fieldName: "这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段", fieldType: "text", sensitiveLevel: "internal", displayAllowed: true, }, { id: "field-internal-readiness-2", fieldKey: "restricted_summary", fieldIndex: 1, fieldName: "受限资料摘要", fieldType: "text", sensitiveLevel: "restricted", displayAllowed: false, }, ], findings: [], }; } type MockState = { archiveMode: "empty" | "record"; }; async function mockApis(page: Page, state: MockState) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const pathName = new URL(request.url()).pathname; if (pathName === "/api/auth/capabilities") { await fulfillJson(route, { emailDelivery: { ready: false }, emailLoginCode: { enabled: false }, passwordReset: { enabled: false }, }); return; } if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/mobile", status: "disabled", statusReasons: [], }); return; } 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-internal-readiness-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, buildMe()); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-internal-readiness", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-internal-readiness", persisted: true, profile: "small_team_internal" }, greeting: { name: "合成员工" }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }); return; } if (pathName === "/api/attendance/monthly") { await fulfillJson(route, { month: "2026-07", summary: { employeeCount: 1, workDays: 1, checkinDays: 1, missingDays: 0, approvedCorrectionCount: 0, }, employees: [ { employee: { id: "employee-internal-readiness", employeeNo: "SYN005", name: "合成员工", title: "工程师", departmentId: "department-internal-readiness", departmentName: "研发部", }, days: [ { date: "2026-07-17", status: "normal", explanation: "", firstCheckinAt: "2026-07-17T01:00:00.000Z", lastCheckinAt: "2026-07-17T10:00:00.000Z", checkins: [ { id: "checkin-internal-readiness", checkinAt: "2026-07-17T01:00:00.000Z", checkinDate: "2026-07-17", checkinType: "on_duty", result: "normal", exceptionType: null, sourceType: "wecom", }, ], corrections: [], leaveRequests: [], }, ], }, ], }); return; } if (pathName === "/api/attendance-corrections") { await fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] }); return; } if (pathName === "/api/wecom/approval-archive") { const items = state.archiveMode === "record" ? [archiveItem()] : []; await fulfillJson(route, { scope: "self", requestedScope: "self", canViewAll: false, page: 1, pageSize: 50, total: items.length, status: "OK", items, }); return; } if (pathName === "/api/wecom/approval-archive/archive-internal-readiness") { await fulfillJson(route, { item: archiveDetail() }); return; } await fulfillJson(route, { message: `unhandled synthetic endpoint: ${pathName}` }, 404); }); } async function login(page: Page) { await page.goto("/login"); const identifier = page.getByTestId("login-identifier"); await expect(identifier).toBeVisible(); await identifier.fill("employee@example.test"); await page.getByTestId("login-password").fill("SyntheticPassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/(?:portal|mobile)$/); } async function newWecomPage(browser: Browser, state: MockState) { const context = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", serviceWorkers: "block", }); const page = await context.newPage(); await mockApis(page, state); await login(page); return { context, page }; } async function expectNarrowPageMetrics(page: Page) { const metrics = await page.evaluate(() => { const scrollContainer = document.querySelector("main.app-shell-main > div.flex-1"); const bottomNav = document.querySelector("[data-testid='mobile-bottom-nav']"); const navHeight = bottomNav?.getBoundingClientRect().height ?? 0; const paddingBottom = scrollContainer ? Number.parseFloat(window.getComputedStyle(scrollContainer).paddingBottom) : 0; return { width: window.innerWidth, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), navHeight, paddingBottom, }; }); expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2); expect(metrics.navHeight).toBeGreaterThan(0); expect(metrics.paddingBottom).toBeGreaterThan(metrics.navHeight); } test.describe("五人团队本地候选移动证据", () => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据,不访问公网"); test("/mobile/attendance 在 375x812 下直达考勤且不再自循环", async ({ browser }) => { const state: MockState = { archiveMode: "empty" }; const mobile = await newWecomPage(browser, state); try { await mobile.page.goto("/mobile/attendance"); await expect(mobile.page).toHaveURL(/\/mobile\/attendance$/); await expect(mobile.page.getByRole("heading", { level: 1, name: "考勤" })).toBeVisible(); await expect(mobile.page.getByText("企业微信主办", { exact: true }).first()).toBeVisible(); await expect(mobile.page.getByText("合成员工", { exact: true }).first()).toBeVisible(); await expectNarrowPageMetrics(mobile.page); await mobile.page.screenshot({ path: screenshotPath("mobile-attendance-375x812.png"), fullPage: true }); } finally { await mobile.context.close(); } }); test("/mobile/wecom-approval-archive 覆盖空态、有记录、详情和长字段", async ({ browser }) => { const state: MockState = { archiveMode: "empty" }; const mobile = await newWecomPage(browser, state); try { await mobile.page.goto("/mobile/wecom-approval-archive"); await expect(mobile.page).toHaveURL(/\/mobile\/wecom-approval-archive$/); await expect(mobile.page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible(); await expect(mobile.page.getByText(/暂无已归档的企微审批历史/)).toBeVisible(); await expectNarrowPageMetrics(mobile.page); await mobile.page.screenshot({ path: screenshotPath("mobile-archive-empty-375x812.png"), fullPage: true }); state.archiveMode = "record"; await mobile.page.reload(); const mobileRecord = mobile.page.getByRole("article").filter({ hasText: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE", }); await expect(mobileRecord).toBeVisible(); await mobileRecord.getByRole("button", { name: "查看历史" }).click(); const longFieldName = mobile.page.getByText( "这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段", { exact: true }, ); const restrictedPlaceholder = mobile.page.getByText("受限字段", { exact: true }); await longFieldName.scrollIntoViewIfNeeded(); await expect(longFieldName).toBeVisible(); await restrictedPlaceholder.scrollIntoViewIfNeeded(); await expect(restrictedPlaceholder).toBeVisible(); await expectNarrowPageMetrics(mobile.page); await mobile.page.screenshot({ path: screenshotPath("mobile-archive-detail-375x812.png"), fullPage: true }); } finally { await mobile.context.close(); } }); test("/wecom-approval-archive 桌面路由保持只读历史入口", async ({ page }) => { const state: MockState = { archiveMode: "record" }; await mockApis(page, state); await login(page); await page.setViewportSize({ width: 1024, height: 768 }); await page.goto("/wecom-approval-archive"); await expect(page).toHaveURL(/\/wecom-approval-archive$/); await expect(page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible(); await expect(page.getByRole("button", { name: "查看历史" })).toBeVisible(); await expect(page.getByRole("button", { name: /同意|驳回|撤销|终止/ })).toHaveCount(0); await page.screenshot({ path: screenshotPath("desktop-archive-1024x768.png"), fullPage: true }); }); });