import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page } from "@playwright/test"; import { EMPLOYEE, createAuthenticatedApiClient, type AuthenticatedApiClient, type E2EUser, } from "./helpers/auth"; const ROOT_DIR = path.resolve(process.cwd(), ".."); const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r109-mobile-feedback"); 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"; type SeededReimbursement = { id: string; description: string; client: AuthenticatedApiClient; }; function screenshotPath(fileName: string) { mkdirSync(SCREENSHOT_DIR, { recursive: true }); return path.join(SCREENSHOT_DIR, `${fileName}.png`); } async function loginForMobile(page: Page, user: E2EUser) { await page.goto("/login"); await expect(page.getByTestId("login-identifier")).toBeVisible(); await page.getByTestId("login-identifier").fill(user.identifier); await page.getByTestId("login-password").fill(user.password); await page.getByTestId("login-submit").click(); await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); await page.waitForLoadState("networkidle").catch(() => undefined); } async function newWecomPage(browser: Browser, viewport: { width: number; height: number }) { const context = await browser.newContext({ viewport, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", serviceWorkers: "block", }); return { context, page: await context.newPage() }; } async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) { const response = await client.api.get("/api/expense-types?enabled=true"); const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } }; const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; if (!response.ok() || !item?.id) { throw new Error(`无法读取费用类型:HTTP ${response.status()}`); } return item.id; } async function seedDraftReimbursement(): Promise { const client = await createAuthenticatedApiClient(EMPLOYEE); const expenseTypeId = await firstEnabledExpenseTypeId(client); const description = `R109 移动报销 ${Date.now()}`; const reimbursementResponse = await client.api.post("/api/reimbursements", { data: { description }, }); const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } }; if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) { throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`); } const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, { data: { expenseTypeId, expenseDate: "2026-05-08", amount: "168.30", description: "R109 移动端默认绑定明细", }, }); if (!itemResponse.ok()) { throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`); } return { id: reimbursementPayload.data.id, description, client }; } async function collectMobileMetrics(page: Page) { return page.evaluate(() => { function parseRgb(value: string) { const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (!match) return null; return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: match[4] == null ? 1 : Number(match[4]), }; } function isVisible(element: Element) { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; } const nav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav"); const navRect = nav?.getBoundingClientRect(); const activeNav = document.querySelector("nav a[aria-current='page']"); const activeStyle = activeNav ? getComputedStyle(activeNav) : null; const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null; const moreLinks = document.querySelector("[data-testid='mobile-more-links']"); const moreRect = moreLinks?.getBoundingClientRect(); return { width: window.innerWidth, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), tableCount: Array.from(document.querySelectorAll("table")).filter(isVisible).length, navHeight: navRect ? Math.round(navRect.height) : 0, activeNavBackground: activeStyle?.backgroundColor ?? "", activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255, activeNavAlpha: activeRgb?.a ?? 0, moreMenuRatio: moreRect ? moreRect.height / window.innerHeight : null, activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "", activeElementTag: document.activeElement?.tagName ?? "", text: document.body?.innerText ?? "", }; }); } test.describe.serial("R109 企业微信移动报销反馈专项", () => { test.skip(process.env.E2E_MODE === "live", "R109 本地移动专项只在临时本地环境运行"); test("移动报销详情、底部导航和添加发票交互可用", async ({ browser }) => { const seeded = await seedDraftReimbursement(); const employee = await newWecomPage(browser, { width: 390, height: 844 }); try { await loginForMobile(employee.page, EMPLOYEE); await employee.page.goto("/mobile/reimbursements"); await employee.page.waitForLoadState("networkidle").catch(() => undefined); const card = employee.page.getByTestId("mobile-reimbursement-card").filter({ hasText: seeded.description }).first(); await expect(card).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-reimbursements") }); await card.getByRole("link", { name: "继续编辑" }).click(); await expect(employee.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}`)); await expect(employee.page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-reimbursement-detail") }); const detailMetrics = await collectMobileMetrics(employee.page); expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2); expect(detailMetrics.tableCount).toBe(0); expect(detailMetrics.navHeight).toBeLessThanOrEqual(68); expect(detailMetrics.text).not.toContain("/reimbursements/"); await employee.page.getByTestId("reimbursement-invoice-add").click(); await expect(employee.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); const dialogOpenMetrics = await collectMobileMetrics(employee.page); expect(dialogOpenMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search"); await employee.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles([ { name: `r109-mobile-${Date.now()}.jpg`, mimeType: "image/jpeg", buffer: Buffer.from(`r109-mobile-upload-${Date.now()}`), }, ]); await employee.page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(employee.page.getByTestId("reimbursement-invoice-upload-submit")).toHaveText("上传发票", { timeout: 20_000, }); await employee.page.getByRole("button", { name: "已有发票" }).click(); await expect(employee.page.getByTestId("mobile-reimbursement-invoice-search")).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-invoice-dialog") }); await employee.page.goto("/mobile/leave"); await expect(employee.page.getByTestId("mobile-leave-form")).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-leave") }); const leaveMetrics = await collectMobileMetrics(employee.page); expect(leaveMetrics.navHeight).toBeLessThanOrEqual(68); expect(leaveMetrics.scrollWidth).toBeLessThanOrEqual(leaveMetrics.width + 2); await employee.page.goto("/mobile/me"); await expect(employee.page.getByTestId("mobile-more-links")).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-me") }); const meMetrics = await collectMobileMetrics(employee.page); expect(meMetrics.moreMenuRatio ?? 0).toBeLessThanOrEqual(0.5); expect(meMetrics.navHeight).toBeLessThanOrEqual(68); expect(meMetrics.activeNavAlpha).toBeGreaterThan(0.9); expect(meMetrics.activeNavAverage, meMetrics.activeNavBackground).toBeLessThan(245); } finally { await seeded.client.api.delete(`/api/reimbursements/${seeded.id}`).catch(() => undefined); await Promise.all([seeded.client.dispose(), employee.context.close()]); } }); });