R70 A7:新增自动辅助试运行脚本
这个提交包含在:
@@ -0,0 +1 @@
|
||||
"ID","Role","Severity","Module","Title","Steps","Expected","Actual","Screenshot","Owner","Disposition","Status","Notes"
|
||||
|
@@ -0,0 +1,27 @@
|
||||
# R70 自动辅助试运行报告
|
||||
|
||||
生成时间:2026-04-26T14:03:49.329Z
|
||||
|
||||
## 结论
|
||||
|
||||
自动辅助试运行未发现阻断步骤。仍需人工判断流程是否顺手、文案是否清晰。
|
||||
|
||||
## 步骤结果
|
||||
|
||||
| Role | Step | Status | Screenshot | Detail |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| employee | Portal 首页 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/01-employee-Portal-首页.png | ok |
|
||||
| employee | 请假提交并生成经理待办 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/02-employee-请假提交并生成经理待办.png | leaveRequestId=af04a716-6dc9-4f74-8c2e-49fde0ed9092; taskId=2a7ab585-ec2e-4689-9c35-e296446d4ca5 |
|
||||
| employee | 报销入口 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/03-employee-报销入口.png | ok |
|
||||
| employee | 周报与待办入口 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/04-employee-周报与待办入口.png | ok |
|
||||
| employee | 公告与全局搜索 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/05-employee-公告与全局搜索.png | ok |
|
||||
| employee | 移动首页 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/06-employee-移动首页.png | ok |
|
||||
| manager | 待我处理与快捷审批入口 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/07-manager-待我处理与快捷审批入口.png | ok |
|
||||
| manager | 团队状态与项目工作台 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/08-manager-团队状态与项目工作台.png | ok |
|
||||
| admin | 管理入口与人员组织 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/09-admin-管理入口与人员组织.png | ok |
|
||||
| admin | 系统设置、功能开关、审计日志 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun/10-admin-系统设置-功能开关-审计日志.png | ok |
|
||||
|
||||
## 说明
|
||||
|
||||
- 截图输出在 `deploy/trialrun/screenshots/r70-auto-trialrun/`,该目录被 `.gitignore` 忽略。
|
||||
- 本报告只证明自动路径可执行,不替代真实用户对体验、文案和流程合理性的判断。
|
||||
@@ -10,6 +10,7 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts",
|
||||
"test:e2e:full": "playwright test",
|
||||
"test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list",
|
||||
"test:e2e:live": "playwright test tests/e2e/live-smoke.spec.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const e2eMode = process.env.E2E_MODE === "live" ? "live" : "local";
|
||||
const r70TrialrunAssist = process.env.R70_TRIALRUN_ASSIST === "1";
|
||||
const baseURL = e2eMode === "live"
|
||||
? process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com"
|
||||
: process.env.E2E_BASE_URL?.trim() || "http://127.0.0.1:3000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: r70TrialrunAssist ? [] : ["**/manual/**"],
|
||||
timeout: 120_000,
|
||||
expect: {
|
||||
timeout: 30_000,
|
||||
},
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
workers: r70TrialrunAssist ? 1 : process.env.CI ? 2 : 4,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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 (await openNavLink(page, "待办")).click();
|
||||
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: "人员管理" })).toBeVisible();
|
||||
await (await openNavLink(page, "组织架构")).click();
|
||||
await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible();
|
||||
});
|
||||
|
||||
await runStep("admin", "系统设置、功能开关、审计日志", async () => {
|
||||
await (await openNavLink(page, "系统设置")).click();
|
||||
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
||||
await (await openNavLink(page, "功能开关")).click();
|
||||
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
|
||||
await (await openNavLink(page, "审计日志")).click();
|
||||
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);
|
||||
});
|
||||
@@ -5,3 +5,4 @@
|
||||
"A4","经理审批与团队协同人工试运行","经理角色按真实管理路径试用待办审批、快捷审批、请假团队日历、团队状态、项目协同和通知。","1) 经理能找到待我处理并完成至少 1 条审批;2) 快捷审批和详情审批语义清晰;3) 团队日历/团队状态可用于判断人员可用性;4) 项目协同入口可理解;5) 问题已写入 issue-log。","业务步骤:经理登录 → 查看 Portal 管理视角 → 进入待我处理 → 快捷审批/详情审批 → 查看团队日历和团队状态 → 查看项目工作台 → 记录问题。","manual","TODO","TODO","TODO","artifacts/r70/issue-log.csv","[""A2"",""A3""]","若出现审批人错误、越权审批、状态跳转异常,标 P0/P1 并单独 review;不在 R70 直接改规则。"
|
||||
"A5","管理员配置与边界人工试运行","管理员角色试用人员管理、组织架构、系统设置、功能开关、审计日志和备用模块边界,记录配置可理解性和权限边界问题。","1) 管理员能找到并理解主要管理入口;2) 普通员工视角不出现管理入口;3) 审计日志可用于追踪关键操作;4) 功能开关/系统设置没有误导性文案;5) 问题已写入 issue-log。","业务步骤:管理员登录 → 人员管理/组织架构只读检查 → 系统设置/功能开关检查 → 审计日志查询关键操作 → 对比员工视角入口边界 → 记录问题。","manual","TODO","TODO","TODO","artifacts/r70/issue-log.csv","[""A2""]","涉及权限边界、审计缺失、配置误生效的问题标 P0/P1 并单独 review;不在 R70 直接改权限模型。"
|
||||
"A6","R70 试运行报告与 R71 缺陷清单","汇总 R70 人工试运行结果,输出通过项、阻断项、非阻断体验问题、R71 修复建议和是否可继续试运行的结论。","1) artifacts/r70/manual-trialrun-report.md 已回填实际执行结果;2) issue-log 中每个问题都有严重级别和处置建议;3) P0/P1 已转入 R71 候选修复;4) chunk/build 警告是否进入 R71 技术优化有明确结论。","业务步骤:收集 A3-A5 记录 → 按 P0/P1/P2/P3 分类 → 给出继续试运行/暂停修复结论 → 形成 R71 候选清单。","manual","TODO","TODO","TODO","artifacts/r70/manual-trialrun-report.md|artifacts/r70/issue-log.csv","[""A3"",""A4"",""A5""]","R70 暂不处理 chunk 警告;若人工试运行无 P0/P1,R71 可优先做构建分包与性能体验优化。"
|
||||
"A7","R70 自动辅助试运行脚本","新增独立触发的 Playwright 自动辅助试运行,不纳入默认 full E2E;自动按员工、经理、管理员路径巡检页面、截图并生成候选问题报告,辅助人工 R70 试用。","1) manual spec 默认被 Playwright full 忽略;2) npm run test:e2e:r70-assist 可单独执行;3) QA 脚本可用临时库启动本地环境执行辅助试运行;4) 生成 auto-trialrun-report 和 issue candidates;5) 不修改业务运行时代码。","命令:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh r70-assist-local;业务:员工路径检查 Portal/请假/报销/周报/搜索/移动首页,经理路径检查待我处理/团队/项目,管理员路径检查人员/组织/系统设置/功能开关/审计日志。","playwright","DONE","DONE","DONE","client/playwright.config.ts|client/package.json|client/tests/e2e/manual/r70-trialrun-assist.spec.ts|scripts/qa/run-web-gates.sh|artifacts/r70/auto-trialrun-report.md|artifacts/r70/auto-issue-candidates.csv","[""A2""]","验证:r70-assist-local 已通过,10 个自动巡检步骤全 PASS,候选问题 CSV 无记录;自动截图不提交。安全边界:自动脚本仅用于临时库/本地环境辅助人工验收,不替代人工体验判断;如发现权限/workflow/状态机异常,需单独 review。回滚路径:撤销 A7 文件与脚本入口。"
|
||||
|
||||
|
+25
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|smoke-live>
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
@@ -402,6 +402,22 @@ qa_run_full_local() {
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_r70_assist_local() {
|
||||
qa_select_port
|
||||
export JOBS_ENABLED="${JOBS_ENABLED:-true}"
|
||||
qa_export_app_env
|
||||
qa_prepare_db
|
||||
qa_build_client
|
||||
qa_start_server
|
||||
|
||||
echo ">>> 执行 R70 自动辅助试运行"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
rm -rf test-results playwright-report
|
||||
E2E_MODE=local npm run test:e2e:r70-assist
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_live_smoke() {
|
||||
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
||||
@@ -432,6 +448,14 @@ case "$MODE" in
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_full_local
|
||||
;;
|
||||
r70-assist-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_r70_assist_local
|
||||
;;
|
||||
smoke-live)
|
||||
qa_require_cmd curl
|
||||
qa_require_cmd node
|
||||
|
||||
在新工单中引用
屏蔽一个用户