diff --git a/artifacts/r70/auto-trialrun-deep-report.md b/artifacts/r70/auto-trialrun-deep-report.md new file mode 100644 index 00000000..3435d693 --- /dev/null +++ b/artifacts/r70/auto-trialrun-deep-report.md @@ -0,0 +1,15 @@ +# R70 深层自动辅助试运行报告 + +生成时间:2026-04-28T13:59:08.610Z + +## 结论 + +深层自动辅助试运行未发现阻断步骤;仍需人工判断整体体验是否顺手。 + +## 步骤结果 + +| Step | Status | Screenshot | Detail | +| --- | --- | --- | --- | +| 周报保存-生成待办-提交-完成闭环 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun-deep/01-周报保存-生成待办-提交-完成闭环.png | weekStart=2027-03-26; token=Playwright 深层联动 1777384727632 | +| 公告-团队状态-Portal 汇总联动 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun-deep/02-公告-团队状态-Portal-汇总联动.png | Playwright 深层公告 1777384727632 | +| 真实全局搜索命中公告并跳转 | PASS | deploy/trialrun/screenshots/r70-auto-trialrun-deep/03-真实全局搜索命中公告并跳转.png | Playwright 深层公告 1777384727632 | diff --git a/artifacts/r70/live-ocr-batch-report.md b/artifacts/r70/live-ocr-batch-report.md new file mode 100644 index 00000000..a154aa6a --- /dev/null +++ b/artifacts/r70/live-ocr-batch-report.md @@ -0,0 +1,20 @@ +# R70 批量 live OCR 辅助复核报告 + +生成时间:2026-04-28T13:59:45.495Z + +## 结果 + +- 总文件数:1 +- 成功:0 +- 失败:1 + +## 明细 + +| 文件 | 状态 | 耗时(ms) | 票据ID | 发票代码 | 发票号码 | 销售方 | 开票日期 | 备注 | +| --- | --- | ---: | --- | --- | --- | --- | --- | --- | +| 北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf | ocr_failed | 696 | 3afbde2e-3e64-4578-8c0d-a05711e5934f | - | - | - | - | The SecretId is not found, please ensure that your SecretId is correct. 可在下方直接重试 OCR,或补齐关键字段后标记为可用。 | + +## 说明 + +- 本报告用于真实腾讯 OCR 的本地辅助复核,不进入常规本地回归。 +- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。 diff --git a/artifacts/r70/manual-trialrun-checklist.md b/artifacts/r70/manual-trialrun-checklist.md index fdc7a462..3f74eb7c 100644 --- a/artifacts/r70/manual-trialrun-checklist.md +++ b/artifacts/r70/manual-trialrun-checklist.md @@ -140,3 +140,9 @@ - 当前 trialrun 数据库内的 `feature_flags` 与 `workspace_settings` 已具备小团队试运行配置;若页面仍显示模块缺失,优先检查 server 进程的运行时 env,而不是先怀疑数据库种子。 - 本地 HTTP 辅助复核不要强制 `COOKIE_SECURE=true`,否则 `127.0.0.1` / `192.168.123.7` 下登录态会失效;该项只在 HTTPS 试运行部署时开启。 - 真实腾讯 OCR 已用运行时注入方式接入旧项目本地配置完成验证;不要把腾讯密钥写回仓库内 `.env`。 +- 本地先跑门禁: + - `cd server && TRIALRUN_RUNTIME_MODE=local node src/scripts/smoke-trialrun-runtime.js` +- 深层真实业务辅助回归: + - `cd client && PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 E2E_BASE_URL=http://127.0.0.1:3001 npm run test:e2e -- tests/e2e/manual/r70-trialrun-deep-assist.spec.ts` +- 批量真实腾讯 OCR 辅助回归: + - `cd client && PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_LIVE_BATCH=1 PLAYWRIGHT_OCR_LIVE_DIR=<目录> E2E_BASE_URL=http://127.0.0.1:3001 npm run test:e2e -- tests/e2e/manual/live-invoice-ocr-batch.spec.ts` diff --git a/artifacts/r70/manual-trialrun-report.md b/artifacts/r70/manual-trialrun-report.md index 576ce5bb..7c40e9fd 100644 --- a/artifacts/r70/manual-trialrun-report.md +++ b/artifacts/r70/manual-trialrun-report.md @@ -64,3 +64,13 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。 - 运行方式:server 进程在本地 `3001` 端口以运行时注入方式加载旧项目本地腾讯配置;密钥未写入本仓库 `server/.env`。 - 自动巡检:`client/tests/e2e/manual/r70-trialrun-assist.spec.ts` 在补齐本地运行时开关后,员工 / 经理 / 管理员 10 个辅助步骤全部通过。 - 环境结论:本地试运行如果出现 Portal、周报、公告、团队、人员管理等入口缺失,先检查 server 运行时 `*_ENABLED` 开关是否齐全;本次已确认数据库开关与工作区配置本身无异常。 + +## R74 自动化压榨补充 + +- 本地门禁:`server/src/scripts/smoke-trialrun-runtime.js` 已支持 `TRIALRUN_RUNTIME_MODE=local`,并在本地 HTTP 口径下校验 env、feature flag、workspace setting 与导航基线,通过。 +- 深层真实业务辅助回归:`client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts` 已通过,覆盖: + - 周报保存 -> 生成待办 -> 提交 -> 完成待办 + - 公告发布 -> 员工可见 -> 团队状态更新 -> Portal 汇总可见 + - 真实全局搜索命中公告并跳转 +- 批量真实腾讯 OCR 辅助回归:`client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts` 已落地并产出 `artifacts/r70/live-ocr-batch-report.md`。 +- 当前外部环境结论:本次 batch live OCR 执行时,服务端返回 `AuthFailure.SecretIdNotFound`,说明当前 3001 运行时注入的腾讯凭据未通过远端鉴权;框架已验证“整批不中断、失败统一汇总”的行为,但未在本轮验证到成功识别路径。 diff --git a/client/package.json b/client/package.json index 0da2110a..a97e5a82 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,8 @@ "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: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", + "test:e2e:live-ocr-batch": "PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_LIVE_BATCH=1 playwright test tests/e2e/manual/live-invoice-ocr-batch.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 5007ab40..8a5caf27 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "@playwright/test"; const e2eMode = process.env.E2E_MODE === "live" ? "live" : "local"; const manualE2e = process.env.PLAYWRIGHT_MANUAL === "1"; const r70TrialrunAssist = process.env.R70_TRIALRUN_ASSIST === "1"; +const r70TrialrunDeepAssist = process.env.R70_TRIALRUN_DEEP_ASSIST === "1"; +const liveOcrBatchAssist = process.env.PLAYWRIGHT_OCR_LIVE_BATCH === "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"; @@ -14,7 +16,7 @@ export default defineConfig({ expect: { timeout: 30_000, }, - workers: r70TrialrunAssist ? 1 : process.env.CI ? 2 : 4, + workers: r70TrialrunAssist || r70TrialrunDeepAssist || liveOcrBatchAssist ? 1 : process.env.CI ? 2 : 4, retries: process.env.CI ? 1 : 0, use: { baseURL, diff --git a/client/tests/e2e/helpers/liveInvoiceOcr.ts b/client/tests/e2e/helpers/liveInvoiceOcr.ts new file mode 100644 index 00000000..015aa1fd --- /dev/null +++ b/client/tests/e2e/helpers/liveInvoiceOcr.ts @@ -0,0 +1,67 @@ +import { expect, type Page } from "@playwright/test"; + +export type LiveInvoiceOcrStatus = "available" | "ocr_failed"; + +export type LiveInvoiceOcrResult = { + status: LiveInvoiceOcrStatus; + detailText: string; + bannerText: string; + durationMs: number; + invoiceId: string | null; + invoiceCode: string | null; + invoiceNumber: string | null; + sellerName: string | null; + totalAmount: string | null; + invoiceDate: string | null; +}; + +function extractField(detailText: string, label: string) { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = detailText.match(new RegExp(`${escaped}([^\\n]+)`)); + return match?.[1]?.trim() || null; +} + +export async function openInvoicePool(page: Page) { + await page.goto("/invoice-pool"); + await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); +} + +export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: string, timeout = 180_000): Promise { + const startedAt = Date.now(); + + await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath); + await page.getByTestId("invoice-upload-submit").click(); + + const detailPanel = page.getByTestId("invoice-detail-panel"); + await expect(detailPanel).toBeVisible({ timeout: 30_000 }); + + await expect + .poll(async () => { + const text = await detailPanel.innerText().catch(() => ""); + if (text.includes("状态:available")) return "available"; + if (text.includes("状态:ocr_failed")) return "ocr_failed"; + return "pending_ocr"; + }, { timeout }) + .not.toBe("pending_ocr"); + + const detailText = await detailPanel.innerText(); + const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => ""); + const status = detailText.includes("状态:ocr_failed") ? "ocr_failed" : "available"; + const invoiceNo = extractField(detailText, "发票号:"); + const [invoiceCode, invoiceNumber] = invoiceNo && invoiceNo.includes("-") + ? invoiceNo.split("-", 2).map((item) => item.trim() || null) + : [invoiceNo, null]; + + return { + status, + detailText, + bannerText, + durationMs: Date.now() - startedAt, + invoiceId: extractField(detailText, "票据ID:"), + invoiceCode, + invoiceNumber, + sellerName: extractField(detailText, "销售方:"), + totalAmount: extractField(detailText, "总金额:"), + invoiceDate: extractField(detailText, "开票日期:"), + }; +} diff --git a/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts b/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts new file mode 100644 index 00000000..5c1bbd89 --- /dev/null +++ b/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts @@ -0,0 +1,103 @@ +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { expect, test } from "../helpers/fixtures"; +import { EMPLOYEE, login, logout } from "../helpers/auth"; +import { openInvoicePool, uploadLiveInvoiceAndWait, type LiveInvoiceOcrResult } from "../helpers/liveInvoiceOcr"; + +type BatchResult = LiveInvoiceOcrResult & { + filePath: string; + fileName: string; +}; + +const rootDir = path.resolve(process.cwd(), ".."); +const reportPath = path.join(rootDir, "artifacts/r70/live-ocr-batch-report.md"); + +function resolveInvoiceFiles() { + const files = new Set(); + const directFile = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim(); + const batchFiles = process.env.PLAYWRIGHT_OCR_LIVE_FILES?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) ?? []; + const batchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() ?? ""; + + if (directFile) { + files.add(path.resolve(directFile)); + } + + for (const file of batchFiles) { + files.add(path.resolve(file)); + } + + if (batchDir && existsSync(batchDir) && statSync(batchDir).isDirectory()) { + for (const entry of readdirSync(batchDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) { + const fullPath = path.join(batchDir, entry); + if (!statSync(fullPath).isFile()) continue; + if (!/\.(pdf|png|jpg|jpeg|webp)$/i.test(entry)) continue; + files.add(path.resolve(fullPath)); + } + } + + return [...files].filter((filePath) => existsSync(filePath)); +} + +function writeReport(results: BatchResult[]) { + mkdirSync(path.dirname(reportPath), { recursive: true }); + + const lines = [ + "# R70 批量 live OCR 辅助复核报告", + "", + `生成时间:${new Date().toISOString()}`, + "", + "## 结果", + "", + `- 总文件数:${results.length}`, + `- 成功:${results.filter((item) => item.status === "available").length}`, + `- 失败:${results.filter((item) => item.status === "ocr_failed").length}`, + "", + "## 明细", + "", + "| 文件 | 状态 | 耗时(ms) | 票据ID | 发票代码 | 发票号码 | 销售方 | 开票日期 | 备注 |", + "| --- | --- | ---: | --- | --- | --- | --- | --- | --- |", + ...results.map((item) => { + const remark = item.status === "ocr_failed" ? (item.bannerText || item.detailText).replaceAll("|", "\\|") : "ok"; + return `| ${item.fileName} | ${item.status} | ${item.durationMs} | ${item.invoiceId ?? "-"} | ${item.invoiceCode ?? "-"} | ${item.invoiceNumber ?? "-"} | ${item.sellerName ?? "-"} | ${item.invoiceDate ?? "-"} | ${remark} |`; + }), + "", + "## 说明", + "", + "- 本报告用于真实腾讯 OCR 的本地辅助复核,不进入常规本地回归。", + "- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。", + ]; + + writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8"); +} + +test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输出报告", async ({ page }) => { + test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); + test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行"); + + const invoiceFiles = resolveInvoiceFiles(); + test.skip(!invoiceFiles.length, "需要提供 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR"); + + const results: BatchResult[] = []; + await login(page, EMPLOYEE); + + try { + await openInvoicePool(page); + + for (const invoiceFilePath of invoiceFiles) { + await openInvoicePool(page); + const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath); + results.push({ + ...result, + filePath: invoiceFilePath, + fileName: path.basename(invoiceFilePath), + }); + } + } finally { + writeReport(results); + await logout(page, EMPLOYEE.name).catch(() => undefined); + } + + const failures = results.filter((item) => item.status === "ocr_failed"); + expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); +}); diff --git a/client/tests/e2e/manual/live-invoice-ocr.spec.ts b/client/tests/e2e/manual/live-invoice-ocr.spec.ts index 03ac9240..3c452a9b 100644 --- a/client/tests/e2e/manual/live-invoice-ocr.spec.ts +++ b/client/tests/e2e/manual/live-invoice-ocr.spec.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { test, expect } from "../helpers/fixtures"; import { EMPLOYEE, login, logout } from "../helpers/auth"; -import { openNavLink } from "../helpers/navigation"; +import { openInvoicePool, uploadLiveInvoiceAndWait } from "../helpers/liveInvoiceOcr"; test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可处理状态", async ({ page }) => { test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); @@ -12,23 +12,11 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可 test.skip(!invoiceFilePath || !existsSync(invoiceFilePath), "需要提供存在的 PLAYWRIGHT_OCR_LIVE_FILE"); await login(page, EMPLOYEE); - await (await openNavLink(page, "票据中心")).click(); - await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); + await openInvoicePool(page); - await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath); - await page.getByTestId("invoice-upload-submit").click(); - - await expect.poll(async () => { - const text = await page.getByTestId("invoice-detail-panel").innerText().catch(() => ""); - if (text.includes("状态:available")) return "available"; - if (text.includes("状态:ocr_failed")) return "ocr_failed"; - return "pending_ocr"; - }, { timeout: 180_000 }).not.toBe("pending_ocr"); - - const detailText = await page.getByTestId("invoice-detail-panel").innerText(); - if (detailText.includes("状态:ocr_failed")) { - const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "OCR 失败"); - throw new Error(`live 腾讯 OCR 失败:${bannerText}`); + const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath); + if (result.status === "ocr_failed") { + throw new Error(`live 腾讯 OCR 失败:${result.bannerText || result.detailText}`); } await expect(page.getByTestId("invoice-detail-panel")).not.toContainText("发票号:---"); diff --git a/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts new file mode 100644 index 00000000..31a3d411 --- /dev/null +++ b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts @@ -0,0 +1,231 @@ +import { expect, test } from "@playwright/test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { ADMIN, EMPLOYEE, apiPostData, createAuthenticatedApiClient, login, logout } from "../helpers/auth"; +import { openNavLink } from "../helpers/navigation"; +import { TEST_IDS } from "../helpers/testIds"; + +type StepStatus = "PASS" | "FAIL"; + +type TrialStep = { + title: string; + status: StepStatus; + detail: string; + screenshot: string; +}; + +const rootDir = path.resolve(process.cwd(), ".."); +const reportPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-report.md"); +const issueCandidatesPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-issues.csv"); +const screenshotDir = path.join(rootDir, "deploy/trialrun/screenshots/r70-auto-trialrun-deep"); + +function formatWeekStart(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); +} + +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} 个失败步骤,需继续人工确认。` + : "深层自动辅助试运行未发现阻断步骤;仍需人工判断整体体验是否顺手。", + "", + "## 步骤结果", + "", + "| Step | Status | Screenshot | Detail |", + "| --- | --- | --- | --- |", + ...steps.map((item) => `| ${item.title} | ${item.status} | ${item.screenshot || "N/A"} | ${item.detail.replaceAll("|", "\\|")} |`), + ]; + + 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-DEEP-${String(index + 1).padStart(3, "0")}`, + "employee", + "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.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); + test.skip(process.env.R70_TRIALRUN_DEEP_ASSIST !== "1", "仅在显式 deep assist 模式下执行"); + test.setTimeout(240_000); + + mkdirSync(screenshotDir, { recursive: true }); + const steps: TrialStep[] = []; + let stepIndex = 0; + + const suffix = Date.now(); + const uniqueToken = `Playwright 深层联动 ${suffix}`; + const weekStart = formatWeekStart(280 + (suffix % 70)); + const announcementTitle = `Playwright 深层公告 ${suffix}`; + const presenceNote = `深层辅助复核 ${suffix}`; + const adminClient = await createAuthenticatedApiClient(ADMIN); + + async function runStep(title: string, action: () => Promise) { + stepIndex += 1; + const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${safeName(title)}.png`); + const relativeScreenshot = path.relative(rootDir, screenshot); + try { + const detail = (await action()) ?? "ok"; + await page.screenshot({ path: screenshot, fullPage: true }); + steps.push({ 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({ title, status: "FAIL", detail, screenshot: relativeScreenshot }); + } + } + + try { + await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/announcements", { + title: announcementTitle, + summary: "用于深层自动辅助试运行验证真实公告、Portal 与搜索链路。", + enabled: true, + priority: 1, + }); + + await login(page, EMPLOYEE); + + await runStep("周报保存-生成待办-提交-完成闭环", async () => { + await (await openNavLink(page, "周报")).click(); + await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible(); + + const weeklyLoadResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok(); + }, { timeout: 30_000 }); + await page.getByLabel("周报归档周").fill(weekStart); + await weeklyLoadResponse; + await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`); + await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`); + await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`); + + const saveResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok(); + }, { timeout: 30_000 }); + await page.getByTestId(TEST_IDS.weeklyReport.save).click(); + await saveResponse; + await expect(page.getByText("周报已保存")).toBeVisible(); + + const createTodoResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok(); + }, { timeout: 30_000 }); + await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click(); + await createTodoResponse; + await expect(page.getByText("已从周报生成待办")).toBeVisible(); + + const submitResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok(); + }, { timeout: 30_000 }); + await page.getByTestId(TEST_IDS.weeklyReport.submit).click(); + await submitResponse; + await expect(page.getByText("周报已提交")).toBeVisible(); + + await (await openNavLink(page, "待办")).click(); + const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first(); + await expect(todoRow).toBeVisible({ timeout: 30_000 }); + await todoRow.getByTestId(TEST_IDS.todo.complete).click(); + await expect(todoRow).toContainText("done", { timeout: 30_000 }); + return `weekStart=${weekStart}; token=${uniqueToken}`; + }); + + await runStep("公告-团队状态-Portal 汇总联动", async () => { + await page.goto("/announcements"); + await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); + await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); + + await page.goto("/team"); + await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); + await page.locator("select.select-std").first().selectOption("remote"); + await page.getByPlaceholder("可选备注,如下午外出办事").fill(presenceNote); + await page.getByRole("button", { name: "保存状态" }).click(); + await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); + + await page.goto("/portal"); + await page.getByRole("tab", { name: "公告" }).click(); + await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); + return announcementTitle; + }); + + await runStep("真实全局搜索命中公告并跳转", async () => { + await page.goto("/portal"); + await page.getByTestId("global-search-trigger").click(); + await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible(); + + const searchResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname === "/api/search" && response.ok(); + }, { timeout: 30_000 }); + await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill(String(suffix)); + await searchResponse; + + const resultButton = page.getByRole("button", { name: new RegExp(announcementTitle) }).first(); + await expect(resultButton).toBeVisible({ timeout: 30_000 }); + await resultButton.click(); + await expect(page).toHaveURL(/\/announcements$/); + await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); + await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); + return announcementTitle; + }); + } finally { + writeReports(steps); + await adminClient.dispose(); + await logout(page, EMPLOYEE.name).catch(() => undefined); + } + + const failures = steps.filter((item) => item.status === "FAIL"); + expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); +}); diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 179b297d..dd22e618 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -261,6 +261,35 @@ docker logs -f hyb-oa-trialrun-server - 建议保留备用:`PAYMENT_BATCH_ENABLED=false`、`CUTOVER_CONSOLE_ENABLED=false` - OCR 回归环境建议:`OCR_PROVIDER=mock` +### 7.2A 本地 Playwright 辅助复核门禁 +1. 本地 HTTP 辅助复核先执行: +```bash +cd server +TRIALRUN_RUNTIME_MODE=local node src/scripts/smoke-trialrun-runtime.js +``` +2. 该命令会同时检查 trialrun 所需 env、feature flag、workspace setting 与导航基线。 +3. `local` 模式允许 `APP_ORIGIN` 为空、`COOKIE_SECURE` 为空或 `false`;HTTPS / 真实部署仍按 `deploy` 模式要求 `APP_ORIGIN=https://...` 且 `COOKIE_SECURE=true`。 + +### 7.2B 本地深层辅助回归命令 +```bash +cd client +PLAYWRIGHT_MANUAL=1 \ +R70_TRIALRUN_DEEP_ASSIST=1 \ +E2E_BASE_URL=http://127.0.0.1:3001 \ +npm run test:e2e -- tests/e2e/manual/r70-trialrun-deep-assist.spec.ts +``` + +### 7.2C 批量真实腾讯 OCR 辅助回归命令 +```bash +cd client +PLAYWRIGHT_MANUAL=1 \ +PLAYWRIGHT_OCR_LIVE=1 \ +PLAYWRIGHT_OCR_LIVE_BATCH=1 \ +PLAYWRIGHT_OCR_LIVE_DIR=/abs/path/to/invoices \ +E2E_BASE_URL=http://127.0.0.1:3001 \ +npm run test:e2e -- tests/e2e/manual/live-invoice-ocr-batch.spec.ts +``` + ### 7.3 上线前整理事项 1. 补齐员工账号、部门、直属上级与员工档案。 2. 核对资产卡片与实际领用关系。 diff --git a/issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv b/issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv new file mode 100644 index 00000000..f717b157 --- /dev/null +++ b/issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv @@ -0,0 +1,5 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"A1","R74 执行合同建档与范围冻结","创建并校验 R74 合同,仅承接“trialrun 本地门禁 + 深层真实业务回归 + live OCR 批量辅助回归”;不承接 CI 接入、像素级视觉 diff、企业微信真实端到端。","issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv 列完整且通过校验;A2-A4 依赖关系清晰;Notes 明确本轮不做像素 diff。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv。","manual","DONE","DONE","DONE","issues/2026-04-28_22-40-00-r74-trialrun-automation-squeeze.csv","[]","回滚路径:删除本 CSV;不影响业务运行。2026-04-28:合同已写入并通过 validate_issues_csv.py 校验。" +"A2","本地试运行环境门禁自动化","调整 trialrun runtime smoke,使其在本地 HTTP 环境下可判定通过,并在跑 Playwright 前检查 trialrun 所需 env、feature flag、workspace setting,提前拦截配置缺口。","存在一条可复用的本地门禁命令;缺少关键 env 时明确失败;本地 HTTP 模式不会因 COOKIE_SECURE 误判失败;runbook 与 checklist 回填启动方式。","命令:cd server && TRIALRUN_RUNTIME_MODE=local node src/scripts/smoke-trialrun-runtime.js。业务:缺少 PORTAL_HOME/WEEKLY_REPORT/PRESENCE 等关键开关时先失败,不进入 Playwright。","node|manual","DONE","DONE","DONE","server/src/scripts/smoke-trialrun-runtime.js|deploy/trialrun/runbook.md|artifacts/r70/manual-trialrun-checklist.md","[""A1""]","本地模式允许 HTTP cookie 配置差异;HTTPS/正式试运行仍保留严格检查。2026-04-28:已补齐 GLOBAL_SEARCH/ATTENDANCE/PROJECT_LINKING 门禁、本地/部署模式分流、工作区显隐与导航基线校验,并在 trialrun DB 上通过 local smoke。" +"A3","真实业务深层 Playwright 辅助回归","在现有 r70 辅助巡检之外,增加真实环境下的周报->待办、公告->团队状态->Portal、全局搜索 smoke,并输出 trialrun 辅助报告。优先复用已有 real-env spec 逻辑,不重造重复断言。","本地显式开关下可一键跑通 3 类真实业务链路;失败时输出到 artifacts/r70 辅助报告;不依赖 mock search 数据。","命令:cd client && PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 E2E_BASE_URL=http://127.0.0.1:3001 npx playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list。业务:周报生成待办并完成、公告发布后员工与 Portal 可见、真实数据下搜索可打开并返回结果。","playwright|manual","DONE","DONE","DONE","client/package.json|client/playwright.config.ts|client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts|client/tests/e2e/weekly-todo-flow.spec.ts|client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/global-search.spec.ts|artifacts/r70/auto-trialrun-deep-report.md","[""A1"",""A2""]","优先串联真实环境链路;mock spec 保留做组件级/交互级稳定回归。2026-04-28:已通过 deep assist,覆盖周报待办闭环、公告与团队状态 Portal 联动、真实全局搜索命中公告并跳转。" +"A4","真实腾讯 OCR 批量辅助回归","把 live OCR 单票辅助复核扩成批量模式,支持目录或显式文件列表输入,并生成每张票的识别状态、耗时、缺失字段与可绑定状态报告。","显式开关下可对多张真实发票批量执行 live OCR;每张票都有结构化结果;失败不会中断整批执行;报告可直接用于人工复核。","命令:cd client && PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_LIVE_BATCH=1 PLAYWRIGHT_OCR_LIVE_DIR= E2E_BASE_URL=http://127.0.0.1:3001 npx playwright test tests/e2e/manual/live-invoice-ocr-batch.spec.ts --workers=1 --reporter=list。业务:多票样上传后分别得到 available/ocr_failed 与原因。","playwright|manual","DONE","TODO","TODO","client/package.json|client/playwright.config.ts|client/tests/e2e/helpers/liveInvoiceOcr.ts|client/tests/e2e/manual/live-invoice-ocr.spec.ts|client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts|artifacts/r70/live-ocr-batch-report.md|artifacts/r70/manual-trialrun-report.md|artifacts/r70/manual-trialrun-checklist.md","[""A1"",""A2""]","不把真实腾讯密钥写入仓库;继续采用运行时注入。2026-04-28:批量框架与结构化报告已落地,单文件 batch 实跑能完整输出失败明细且不中断后续文件;当前 trialrun 3001 运行时返回 `AuthFailure.SecretIdNotFound`,尚未验证成功识别路径。" diff --git a/server/src/scripts/smoke-trialrun-runtime.js b/server/src/scripts/smoke-trialrun-runtime.js index 99be83f7..5bcbb421 100644 --- a/server/src/scripts/smoke-trialrun-runtime.js +++ b/server/src/scripts/smoke-trialrun-runtime.js @@ -11,37 +11,144 @@ const { resolvePermissionsForRoles, hasPermission } = require('../constants/perm const REQUIRED_ENV_VALUES = { PORTAL_HOME_ENABLED: 'true', + GLOBAL_SEARCH_ENABLED: 'true', ANNOUNCEMENTS_ENABLED: 'true', PRESENCE_BOARD_ENABLED: 'true', + ATTENDANCE_ENABLED: 'true', WEEKLY_REPORT_ENABLED: 'true', EMPLOYEE_LIFECYCLE_ENABLED: 'true', TODO_BOARD_ENABLED: 'true', PROJECT_DOMAIN_ENABLED: 'true', + PROJECT_LINKING_ENABLED: 'true', PROJECT_COMMUNICATION_LOG_ENABLED: 'true', PROJECT_CLOSURE_CHECKLIST_ENABLED: 'true', - COOKIE_SECURE: 'true', }; -const REQUIRED_NON_EMPTY_ENV_KEYS = ['APP_ORIGIN']; - const REQUIRED_FLAGS = [ FEATURE_FLAG_KEYS.portalHome, + FEATURE_FLAG_KEYS.globalSearch, FEATURE_FLAG_KEYS.announcements, FEATURE_FLAG_KEYS.presenceBoard, + FEATURE_FLAG_KEYS.attendance, FEATURE_FLAG_KEYS.weeklyReport, FEATURE_FLAG_KEYS.employeeLifecycle, FEATURE_FLAG_KEYS.todoBoard, FEATURE_FLAG_KEYS.projectDomain, + FEATURE_FLAG_KEYS.projectLinking, FEATURE_FLAG_KEYS.projectCommunicationLog, FEATURE_FLAG_KEYS.projectClosureChecklist, ]; +const REQUIRED_MAIN_NAV_KEYS = [ + 'portal', + 'team', + 'leaveRequests', + 'reimbursements', + 'assets', + 'weeklyReports', + 'todos', + 'announcements', +]; + +const REQUIRED_ADMIN_NAV_KEYS = [ + 'workspace', + 'people', + 'systemSettings', + 'featureFlags', + 'auditLogs', +]; + +const REQUIRED_WORKSPACE_VISIBILITY = { + portal: 'main', + team: 'main', + leaveRequests: 'main', + reimbursements: 'main', + assets: 'main', + weeklyReports: 'main', + todos: 'main', + announcements: 'main', + people: 'admin', + workspace: 'admin', + systemSettings: 'admin', + featureFlags: 'admin', + auditLogs: 'admin', +}; + function assertCondition(condition, message) { if (!condition) { throw new Error(message); } } +function resolveRuntimeMode() { + const explicit = String(process.env.TRIALRUN_RUNTIME_MODE ?? '').trim().toLowerCase(); + if (explicit === 'local' || explicit === 'deploy') { + return explicit; + } + + const appOrigin = String(process.env.APP_ORIGIN ?? '').trim(); + if (appOrigin.startsWith('https://') || process.env.COOKIE_SECURE === 'true') { + return 'deploy'; + } + return 'local'; +} + +function assertEnvValues(runtimeMode) { + for (const [key, expected] of Object.entries(REQUIRED_ENV_VALUES)) { + const actual = process.env[key]; + assertCondition(actual === expected, `${key} 期望为 ${expected},实际为 ${actual ?? ''}`); + console.log(`[PASS] ${key}=${actual}`); + } + + const appOrigin = String(process.env.APP_ORIGIN ?? '').trim(); + if (runtimeMode === 'deploy') { + assertCondition(appOrigin.length > 0, 'APP_ORIGIN 不能为空'); + assertCondition(appOrigin.startsWith('https://'), `deploy 模式下 APP_ORIGIN 必须为 https,实际为 ${appOrigin}`); + assertCondition(process.env.COOKIE_SECURE === 'true', `deploy 模式下 COOKIE_SECURE 期望为 true,实际为 ${process.env.COOKIE_SECURE ?? ''}`); + console.log(`[PASS] APP_ORIGIN=${appOrigin}`); + console.log('[PASS] COOKIE_SECURE=true'); + return; + } + + if (appOrigin) { + assertCondition(/^https?:\/\//.test(appOrigin), `local 模式下 APP_ORIGIN 如提供,必须为 http/https,实际为 ${appOrigin}`); + console.log(`[PASS] APP_ORIGIN=${appOrigin}`); + } else { + console.log('[PASS] APP_ORIGIN=(local 模式允许)'); + } + + const cookieSecure = process.env.COOKIE_SECURE; + assertCondition(cookieSecure == null || cookieSecure === '' || cookieSecure === 'false', `local 模式下 COOKIE_SECURE 应为空或 false,实际为 ${cookieSecure ?? ''}`); + console.log(`[PASS] COOKIE_SECURE=${cookieSecure ?? ''}(local 模式允许)`); +} + +function assertWorkspaceSetting(workspace) { + assertCondition(Boolean(workspace), 'WorkspaceSetting 读取失败'); + console.log(`[PASS] 工作区设置可读,profile=${workspace.profile}`); + + for (const [key, expected] of Object.entries(REQUIRED_WORKSPACE_VISIBILITY)) { + const actual = workspace.moduleVisibility?.[key]; + assertCondition(actual === expected, `workspace.moduleVisibility.${key} 期望为 ${expected},实际为 ${actual ?? ''}`); + } + console.log(`[PASS] 工作区模块显隐符合 trialrun 基线,共校验 ${Object.keys(REQUIRED_WORKSPACE_VISIBILITY).length} 项`); +} + +function assertNavigation(navigation) { + assertCondition(Array.isArray(navigation.sections?.main), '系统导航主区块生成失败'); + assertCondition(Array.isArray(navigation.sections?.admin), '系统导航管理区块生成失败'); + + const mainKeys = new Set((navigation.sections.main ?? []).map((item) => item.key)); + const adminKeys = new Set((navigation.sections.admin ?? []).map((item) => item.key)); + + for (const key of REQUIRED_MAIN_NAV_KEYS) { + assertCondition(mainKeys.has(key), `系统导航 main 缺少 ${key} 入口`); + } + for (const key of REQUIRED_ADMIN_NAV_KEYS) { + assertCondition(adminKeys.has(key), `系统导航 admin 缺少 ${key} 入口`); + } + console.log(`[PASS] 导航构建成功,main=${navigation.sections.main.length} admin=${navigation.sections.admin.length}`); +} + async function main() { const config = getConfig(); const { prisma, pool } = createPrismaClient(); @@ -49,21 +156,13 @@ async function main() { try { console.log('[R26] 开始 trialrun runtime smoke'); + const runtimeMode = resolveRuntimeMode(); + console.log(`[R26] runtimeMode=${runtimeMode}`); await prisma.$queryRaw`SELECT 1`; console.log('[PASS] Prisma 连接正常'); - for (const [key, expected] of Object.entries(REQUIRED_ENV_VALUES)) { - const actual = process.env[key]; - assertCondition(actual === expected, `${key} 期望为 ${expected},实际为 ${actual ?? ''}`); - console.log(`[PASS] ${key}=${actual}`); - } - - for (const key of REQUIRED_NON_EMPTY_ENV_KEYS) { - const actual = process.env[key]; - assertCondition(typeof actual === 'string' && actual.trim().length > 0, `${key} 不能为空`); - console.log(`[PASS] ${key} 已配置`); - } + assertEnvValues(runtimeMode); const adminEmail = (process.env.TRIALRUN_ADMIN_EMAIL || 'admin@hyinda.com').trim(); const adminEmployee = await prisma.employee.findFirst({ @@ -83,8 +182,7 @@ async function main() { console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.email}`); const workspace = await getWorkspaceSetting(prisma); - assertCondition(Boolean(workspace), 'WorkspaceSetting 读取失败'); - console.log(`[PASS] 工作区设置可读,profile=${workspace.profile}`); + assertWorkspaceSetting(workspace); const employeeForGate = { ...adminEmployee, @@ -115,12 +213,7 @@ async function main() { hasPermission: (employee, permissionCode) => hasPermission(employee.permissionCodes ?? [], permissionCode), }); - assertCondition(Array.isArray(navigation.sections?.main), '系统导航主区块生成失败'); - assertCondition( - navigation.sections.main.some((item) => item.key === 'portal'), - '系统导航未生成 portal 入口', - ); - console.log(`[PASS] 导航构建成功,main=${navigation.sections.main.length} admin=${navigation.sections.admin.length}`); + assertNavigation(navigation); console.log('[R26][PASS] trialrun runtime smoke 通过'); } finally {