N3 第三轮候选 98bab397:浏览器 371 通过、1 失败;报销付款与 PWA 升级通过。 桌面企微历史测试整页导航后等待标题超时,快照退回登录页。 确认默认桌面 context 漏掉已有的 Service Worker 隔离;原运行没有请求来源证据,历史唯一根因未证实。 补文件级隔离,验证原导航实际获得该 fixture 的 refresh/profile 响应:200、非 Worker、requestId 对应。 原三项用例、登录/会话、桌面只读、手机布局及截图保持。受管定向 3 项通过,无失败/跳过/重试,环境和清理通过。 应用、资金规则、迁移、门禁范围、预算和全局并发未改。前三轮失败回执保留。 第四轮完整候选结果以新回执为准;独立代码复核及真实还款待验。 仅本地提交,不推送、不部署,不访问生产或真实 OCR。
419 行
15 KiB
TypeScript
419 行
15 KiB
TypeScript
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
|
|
|
test.use({ serviceWorkers: "block" });
|
|
|
|
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright"));
|
|
mkdirSync(visualRoot, { recursive: true });
|
|
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "internal-readiness-ui-"));
|
|
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: "[email protected]",
|
|
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("[email protected]");
|
|
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<HTMLElement>("main.app-shell-main > div.flex-1");
|
|
const bottomNav = document.querySelector<HTMLElement>("[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 });
|
|
const [refreshResponse, meResponse] = await Promise.all([
|
|
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/auth/refresh"),
|
|
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/me"),
|
|
page.goto("/wecom-approval-archive"),
|
|
]);
|
|
for (const response of [refreshResponse, meResponse]) {
|
|
expect(response.status()).toBe(200);
|
|
expect(response.fromServiceWorker()).toBe(false);
|
|
expect((await response.json()).requestId).toBe("mock-internal-readiness-ui");
|
|
}
|
|
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 });
|
|
});
|
|
});
|