diff --git a/artifacts/r70/auto-issue-candidates.csv b/artifacts/r70/auto-issue-candidates.csv new file mode 100644 index 00000000..074a6de2 --- /dev/null +++ b/artifacts/r70/auto-issue-candidates.csv @@ -0,0 +1 @@ +"ID","Role","Severity","Module","Title","Steps","Expected","Actual","Screenshot","Owner","Disposition","Status","Notes" diff --git a/artifacts/r70/auto-trialrun-report.md b/artifacts/r70/auto-trialrun-report.md new file mode 100644 index 00000000..7d681bf8 --- /dev/null +++ b/artifacts/r70/auto-trialrun-report.md @@ -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` 忽略。 +- 本报告只证明自动路径可执行,不替代真实用户对体验、文案和流程合理性的判断。 diff --git a/client/package.json b/client/package.json index f0ce1879..0da2110a 100644 --- a/client/package.json +++ b/client/package.json @@ -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" }, diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 6062628c..9a1b83e2 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -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, diff --git a/client/tests/e2e/manual/r70-trialrun-assist.spec.ts b/client/tests/e2e/manual/r70-trialrun-assist.spec.ts new file mode 100644 index 00000000..286bcdb9 --- /dev/null +++ b/client/tests/e2e/manual/r70-trialrun-assist.spec.ts @@ -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) { + 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); +}); diff --git a/issues/2026-04-26_r70-manual-trialrun.csv b/issues/2026-04-26_r70-manual-trialrun.csv index 839676e6..3640a6c4 100644 --- a/issues/2026-04-26_r70-manual-trialrun.csv +++ b/issues/2026-04-26_r70-manual-trialrun.csv @@ -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 文件与脚本入口。" diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 5f4af853..5bf3b97e 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -36,7 +36,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: 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