R109/A6,A7 增加移动报销视觉专项回归
这个提交包含在:
@@ -12,6 +12,7 @@
|
||||
"test:e2e:full": "playwright test",
|
||||
"test:e2e:r106-visual-audit": "playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:r108-mobile-visual": "playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:r109-mobile-feedback": "playwright test tests/e2e/r109-mobile-reimbursement-feedback.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:r75-gap-fill": "playwright test tests/e2e/r75-automation-gap-fill.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:r70-deep-assist": "PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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<SeededReimbursement> {
|
||||
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()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户