271 行
11 KiB
TypeScript
271 行
11 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { ADMIN, EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "../helpers/auth";
|
|
import { openNavLink } from "../helpers/navigation";
|
|
import { TEST_IDS } from "../helpers/testIds";
|
|
|
|
type StepStatus = "PASS" | "FAIL";
|
|
|
|
type TrialStep = {
|
|
role: "employee" | "manager" | "admin";
|
|
title: string;
|
|
status: StepStatus;
|
|
detail: string;
|
|
screenshot: string;
|
|
};
|
|
|
|
const rootDir = path.resolve(process.cwd(), "..");
|
|
const reportPath = path.join(rootDir, "artifacts/r70/auto-trialrun-report.md");
|
|
const issueCandidatesPath = path.join(rootDir, "artifacts/r70/auto-issue-candidates.csv");
|
|
const screenshotDir = path.join(rootDir, "deploy/trialrun/screenshots/r70-auto-trialrun");
|
|
|
|
function formatDate(offsetDays: number) {
|
|
const value = new Date();
|
|
value.setUTCDate(value.getUTCDate() + offsetDays);
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function csvCell(value: string) {
|
|
return `"${value.replaceAll('"', '""')}"`;
|
|
}
|
|
|
|
function safeName(value: string) {
|
|
return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80);
|
|
}
|
|
|
|
async function selectFirstEnabledOption(page: Page, selector: string) {
|
|
const locator = page.locator(selector);
|
|
await expect
|
|
.poll(async () => {
|
|
const optionValues = await locator.locator("option").evaluateAll((options) =>
|
|
options
|
|
.map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled }))
|
|
.filter((option) => option.value && !option.disabled)
|
|
.map((option) => option.value),
|
|
);
|
|
return optionValues[0] ?? "";
|
|
})
|
|
.not.toBe("");
|
|
|
|
const [firstValue] = await locator.locator("option").evaluateAll((options) =>
|
|
options
|
|
.map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled }))
|
|
.filter((option) => option.value && !option.disabled)
|
|
.map((option) => option.value),
|
|
);
|
|
|
|
await locator.selectOption(firstValue);
|
|
}
|
|
|
|
async function createAndSubmitLeaveForManager(page: Page) {
|
|
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
|
const startDate = formatDate(260 + (Date.now() % 30));
|
|
try {
|
|
const createResponsePromise = page.waitForResponse(
|
|
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
|
|
);
|
|
await selectFirstEnabledOption(page, "#leave-type");
|
|
await page.locator("#leave-start-date").fill(startDate);
|
|
await page.locator("#leave-end-date").fill(startDate);
|
|
await page.locator("#leave-start-session").selectOption("am");
|
|
await page.locator("#leave-end-session").selectOption("pm");
|
|
await page.locator("#leave-reason").fill(`R70 自动辅助试运行 ${Date.now()}`);
|
|
await page.getByRole("button", { name: "创建草稿" }).click();
|
|
|
|
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
|
const leaveRequestId = createPayload.data?.id;
|
|
if (!leaveRequestId) {
|
|
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
|
|
}
|
|
|
|
const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>(
|
|
employeeClient.api,
|
|
`/api/leave-requests/${leaveRequestId}/submit`,
|
|
{},
|
|
);
|
|
return { startDate, leaveRequestId, currentTaskId: submitResult.currentTaskId };
|
|
} finally {
|
|
await employeeClient.dispose();
|
|
}
|
|
}
|
|
|
|
function writeReports(steps: TrialStep[]) {
|
|
mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
const failures = steps.filter((item) => item.status === "FAIL");
|
|
const now = new Date().toISOString();
|
|
|
|
const lines = [
|
|
"# R70 自动辅助试运行报告",
|
|
"",
|
|
`生成时间:${now}`,
|
|
"",
|
|
"## 结论",
|
|
"",
|
|
failures.length
|
|
? `自动辅助试运行发现 ${failures.length} 个失败步骤,需人工确认后写入 R70 issue-log。`
|
|
: "自动辅助试运行未发现阻断步骤。仍需人工判断流程是否顺手、文案是否清晰。",
|
|
"",
|
|
"## 步骤结果",
|
|
"",
|
|
"| Role | Step | Status | Screenshot | Detail |",
|
|
"| --- | --- | --- | --- | --- |",
|
|
...steps.map((item) =>
|
|
`| ${item.role} | ${item.title} | ${item.status} | ${item.screenshot || "N/A"} | ${item.detail.replaceAll("|", "\\|")} |`,
|
|
),
|
|
"",
|
|
"## 说明",
|
|
"",
|
|
"- 截图输出在 `deploy/trialrun/screenshots/r70-auto-trialrun/`,该目录被 `.gitignore` 忽略。",
|
|
"- 本报告只证明自动路径可执行,不替代真实用户对体验、文案和流程合理性的判断。",
|
|
];
|
|
|
|
writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8");
|
|
|
|
const issueHeader = [
|
|
"ID",
|
|
"Role",
|
|
"Severity",
|
|
"Module",
|
|
"Title",
|
|
"Steps",
|
|
"Expected",
|
|
"Actual",
|
|
"Screenshot",
|
|
"Owner",
|
|
"Disposition",
|
|
"Status",
|
|
"Notes",
|
|
];
|
|
const issueRows = failures.map((item, index) =>
|
|
[
|
|
`R70-AUTO-${String(index + 1).padStart(3, "0")}`,
|
|
item.role,
|
|
"P1",
|
|
item.title,
|
|
`自动辅助试运行失败:${item.title}`,
|
|
item.title,
|
|
"页面可打开且关键元素可见",
|
|
item.detail,
|
|
item.screenshot || "N/A",
|
|
"TBD",
|
|
"needs review",
|
|
"OPEN",
|
|
"由 R70 自动辅助试运行生成,需人工复核严重级别。",
|
|
].map(csvCell).join(","),
|
|
);
|
|
writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8");
|
|
}
|
|
|
|
test("R70 自动辅助试运行:员工、经理、管理员路径巡检", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
mkdirSync(screenshotDir, { recursive: true });
|
|
const steps: TrialStep[] = [];
|
|
let stepIndex = 0;
|
|
let managerTaskId = "";
|
|
|
|
async function runStep(role: TrialStep["role"], title: string, action: () => Promise<string | void>) {
|
|
stepIndex += 1;
|
|
const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${role}-${safeName(title)}.png`);
|
|
const relativeScreenshot = path.relative(rootDir, screenshot);
|
|
try {
|
|
const detail = (await action()) ?? "ok";
|
|
await page.screenshot({ path: screenshot, fullPage: true });
|
|
steps.push({ role, title, status: "PASS", detail, screenshot: relativeScreenshot });
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
|
|
steps.push({ role, title, status: "FAIL", detail, screenshot: relativeScreenshot });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await runStep("employee", "Portal 首页", async () => {
|
|
await login(page, EMPLOYEE);
|
|
await expect(page.getByRole("heading", { name: /今日待办聚合|今日工作台/ }).first()).toBeVisible();
|
|
});
|
|
|
|
await runStep("employee", "请假提交并生成经理待办", async () => {
|
|
await (await openNavLink(page, "请假记录")).click();
|
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
|
const leave = await createAndSubmitLeaveForManager(page);
|
|
await page.goto("/leave-requests");
|
|
managerTaskId = leave.currentTaskId;
|
|
await expect(page.getByRole("row").filter({ hasText: leave.startDate }).first()).toContainText("审批中", { timeout: 30_000 });
|
|
return `leaveRequestId=${leave.leaveRequestId}; taskId=${leave.currentTaskId}`;
|
|
});
|
|
|
|
await runStep("employee", "报销入口", async () => {
|
|
await (await openNavLink(page, "我的报销")).click();
|
|
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
|
|
});
|
|
|
|
await runStep("employee", "周报与待办入口", async () => {
|
|
await (await openNavLink(page, "周报")).click();
|
|
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
|
|
await page.goto("/todos");
|
|
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
|
|
});
|
|
|
|
await runStep("employee", "公告与全局搜索", async () => {
|
|
await (await openNavLink(page, "公告")).click();
|
|
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
|
await page.getByTestId("global-search-trigger").click();
|
|
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
|
});
|
|
|
|
await runStep("employee", "移动首页", async () => {
|
|
try {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto("/mobile");
|
|
await expect(page.getByRole("heading", { name: "移动首页" })).toBeVisible();
|
|
} finally {
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
}
|
|
});
|
|
|
|
await runStep("manager", "待我审批与快捷审批入口", async () => {
|
|
await logout(page, EMPLOYEE.name);
|
|
await login(page, MANAGER);
|
|
await (await openNavLink(page, "待我审批")).click();
|
|
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
|
if (managerTaskId) {
|
|
await expect(page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: managerTaskId }).first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
await runStep("manager", "团队状态与项目工作台", async () => {
|
|
await (await openNavLink(page, "团队")).click();
|
|
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
|
|
await (await openNavLink(page, "项目")).click();
|
|
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
|
|
});
|
|
|
|
await runStep("admin", "管理入口与人员组织", async () => {
|
|
await logout(page, MANAGER.name);
|
|
await login(page, ADMIN);
|
|
await (await openNavLink(page, /^管理$/)).click();
|
|
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
|
|
await page.getByRole("link", { name: /人员/ }).first().click();
|
|
await expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible();
|
|
await page.goto("/organization");
|
|
await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible();
|
|
});
|
|
|
|
await runStep("admin", "管理、功能管理、审计日志", async () => {
|
|
await (await openNavLink(page, /^管理$/)).click();
|
|
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
|
|
await page.getByRole("link", { name: /功能管理/ }).first().click();
|
|
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
|
|
await page.goto("/audit-logs");
|
|
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
|
});
|
|
} finally {
|
|
writeReports(steps);
|
|
}
|
|
|
|
const failures = steps.filter((item) => item.status === "FAIL");
|
|
expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0);
|
|
});
|