diff --git a/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md b/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md new file mode 100644 index 00000000..0d88c4e2 --- /dev/null +++ b/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md @@ -0,0 +1,50 @@ +# R105 真实企业微信手机最终落点检查 + +日期:2026-05-07 + +## 目的 + +确认真实手机企业微信客户端中,已绑定 OA 账号的员工从工作台打开 OA 应用后,能直接进入移动工作台 `/mobile`。 + +Playwright 已能覆盖普通浏览器、移动视口、企业微信 OAuth 跳转边界和公网页面视觉,但不能完全替代真实企业微信客户端内的最终授权和 Cookie 行为。 + +## 操作步骤 + +1. 打开手机企业微信。 +2. 进入当前企业的工作台。 +3. 点击 OA 应用。 +4. 等待页面完成跳转,不要用普通浏览器复制链接替代。 +5. 观察最终页面地址和页面内容。 + +## 通过标准 + +- 最终进入 OA 移动工作台,地址应为 `/mobile` 或移动工作台内页。 +- 不再要求输入 OA 账号密码。 +- 底部显示移动导航:首页、审批、票据、我的。 +- 页面没有出现 `AUTH_003`、`missing_user_id`、`当前企业微信尚未绑定`、反复登录等提示。 + +## 失败时记录 + +请记录以下信息,再交给开发排查: + +- 北京时间: +- 手机系统: +- 企业微信版本: +- 当前页面截图: +- 页面显示的 `requestId`: +- 错误码和错误文案: +- 是否已在个人设置中绑定企业微信: +- 是否从企业微信工作台入口进入: + +## 自动化边界 + +已自动化覆盖: + +- `https://oa.hyinda.com/ready` +- `/api/auth/wecom/config` +- `/api/auth/wecom/login?returnTo=%2Fmobile` 跳转到企业微信 OAuth +- 公网移动登录页、移动首页、移动报销、移动请假、移动票据上传、移动审批和 PC 票据/报销页面视觉检查 + +仍需人工确认: + +- 真实企业微信客户端完成 OAuth 后是否直接落到 `/mobile` diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 2fb052aa..dccf8d94 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -197,7 +197,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { return ( diff --git a/client/src/components/navigation/Sidebar.tsx b/client/src/components/navigation/Sidebar.tsx index 6dacb320..bffcf125 100644 --- a/client/src/components/navigation/Sidebar.tsx +++ b/client/src/components/navigation/Sidebar.tsx @@ -11,7 +11,7 @@ export function Sidebar() { 今日工作台 -
先处理待办和团队状态,再进入具体模块。
+
待处理、团队状态和常用入口。
diff --git a/client/src/pages/ProjectsPage.tsx b/client/src/pages/ProjectsPage.tsx index 4f8a9ce1..7abf6bd9 100644 --- a/client/src/pages/ProjectsPage.tsx +++ b/client/src/pages/ProjectsPage.tsx @@ -176,7 +176,7 @@ export function ProjectsPage() {
- 0 ? "warning" : "neutral"} /> + 0 ? "warning" : "neutral"} /> 0 ? "danger" : "neutral"} />
diff --git a/client/tests/e2e/r105-pretrial-visual.spec.ts b/client/tests/e2e/r105-pretrial-visual.spec.ts new file mode 100644 index 00000000..c8f9a6ee --- /dev/null +++ b/client/tests/e2e/r105-pretrial-visual.spec.ts @@ -0,0 +1,135 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { getExpectedWecomStatus, loginLiveUser } from "./helpers/liveAuth"; +import { fetchWecomConfig } from "./helpers/smokeAssertions"; + +const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r105"); + +function screenshotPath(fileName: string) { + mkdirSync(screenshotDir, { recursive: true }); + return path.join(screenshotDir, fileName); +} + +async function expectNoLegacyDesignCopy(page: Page) { + const bodyText = await page.locator("body").innerText(); + expect(bodyText).not.toContain("先处理"); + expect(bodyText).not.toContain("清爽"); + expect(bodyText).not.toContain("低频模块放在这里"); + expect(bodyText).not.toContain("/invoice-pool"); + expect(bodyText).not.toContain("/purchase-requests"); +} + +async function expectNoDesktopTableOnMobile(page: Page) { + await expect(page.locator("table")).toHaveCount(0); +} + +function parseRgb(value: string) { + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?/i); + if (!match) { + return null; + } + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] == null ? 1 : Number(match[4]), + }; +} + +async function expectNotBlackSurface(locator: Locator) { + const backgroundColor = await locator.evaluate((element) => { + return window.getComputedStyle(element).backgroundColor; + }); + const rgb = parseRgb(backgroundColor); + if (!rgb) { + return; + } + if (rgb.a === 0) { + return; + } + const average = (rgb.r + rgb.g + rgb.b) / 3; + expect(average, `backgroundColor=${backgroundColor}`).toBeGreaterThan(24); +} + +test.describe("R105 pretrial visual acceptance", () => { + test.skip(process.env.E2E_MODE !== "live", "仅在公网 live 模式下执行"); + + test("公网登录与企业微信入口仍默认进入移动工作台", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + + await expect(page.getByRole("button", { name: /企业微信/ })).toBeVisible(); + const wecomConfig = await fetchWecomConfig(page); + expect(wecomConfig.status).toBe(200); + expect(wecomConfig.data?.status).toBe(getExpectedWecomStatus()); + expect(wecomConfig.data?.defaultReturnTo).toBe("/mobile"); + expect(wecomConfig.data?.loginUrl).toContain("returnTo=%2Fmobile"); + + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("mobile-login-wecom-entry.png"), fullPage: true }); + }); + + test("员工移动首页、票据上传和我的页面保持手机体验", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await loginLiveUser(page, "employee"); + + await page.goto("/mobile"); + await expect(page.getByTestId("mobile-nav-home")).toBeVisible(); + await expect(page.getByTestId("mobile-nav-capture")).toBeVisible(); + await expectNoDesktopTableOnMobile(page); + await expectNoLegacyDesignCopy(page); + await expectNotBlackSurface(page.getByTestId("mobile-nav-home")); + await page.screenshot({ path: screenshotPath("employee-mobile-home.png"), fullPage: true }); + + await page.goto("/mobile/capture"); + await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible(); + const fileInput = page.getByTestId("mobile-capture-file"); + await expect(fileInput).toHaveAttribute("multiple", ""); + await expect(page.getByTestId("mobile-capture-upload")).toBeVisible(); + await expectNoDesktopTableOnMobile(page); + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("employee-mobile-capture.png"), fullPage: true }); + + await page.goto("/mobile/me"); + await expect(page.getByRole("heading", { name: "我的", exact: true })).toBeVisible(); + await expect(page.getByTestId("mobile-more-links")).toBeVisible(); + await expectNoDesktopTableOnMobile(page); + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("employee-mobile-me.png"), fullPage: true }); + }); + + test("管理员移动审批页不退回桌面表格体验", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await loginLiveUser(page, "admin"); + + await page.goto("/mobile/approvals"); + await expect(page.getByRole("heading", { name: /审批/ })).toBeVisible(); + await expect(page.getByText("待处理").first()).toBeVisible(); + await expectNoDesktopTableOnMobile(page); + await expectNoLegacyDesignCopy(page); + await expectNotBlackSurface(page.getByTestId("mobile-nav-approvals")); + await page.screenshot({ path: screenshotPath("admin-mobile-approvals.png"), fullPage: true }); + }); + + test("PC Portal、报销和票据中心保持试运行首页一致性", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }); + await loginLiveUser(page, "employee"); + + await page.goto("/portal"); + await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true }); + + await page.goto("/reimbursements"); + await expect(page.getByRole("heading", { name: /报销/ })).toBeVisible(); + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true }); + + await page.goto("/invoice-pool"); + await expect(page.getByRole("heading", { name: "票据中心", exact: true })).toBeVisible(); + await expectNoLegacyDesignCopy(page); + await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true }); + }); +}); diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index e8beef56..b5ffc26c 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -14,7 +14,7 @@ const scanRoots = [ "client/src/pages", ]; const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]); -const forbiddenCopy = ["先处理这几件事", "清爽"]; +const forbiddenCopy = ["先处理", "清爽"]; const findings = []; function toPosix(value) { diff --git a/scripts/qa/run-pretrial-readiness.sh b/scripts/qa/run-pretrial-readiness.sh new file mode 100755 index 00000000..0167af6b --- /dev/null +++ b/scripts/qa/run-pretrial-readiness.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT_DIR="$ROOT_DIR/client" +SERVER_DIR="$ROOT_DIR/server" +ISSUE_CSV="$ROOT_DIR/issues/2026-05-07_12-34-21-r105-pretrial-readiness-automation.csv" +ISSUE_VALIDATOR="/home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py" +PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" +MODE="static" + +usage() { + cat </dev/null 2>&1 || { + echo "[pretrial][FAIL] 缺少命令: $1" >&2 + exit 1 + } +} + +has_live_smoke_credentials() { + [[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] && + [[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] && + [[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] && + [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]] +} + +has_live_ocr_credentials() { + if [[ -n "${PLAYWRIGHT_OCR_LIVE_IDENTIFIER:-}" && -n "${PLAYWRIGHT_OCR_LIVE_PASSWORD:-}" ]]; then + return 0 + fi + + [[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] && [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]] +} + +run_static() { + require_cmd python3 + require_cmd node + require_cmd npm + + echo ">>> [pretrial] CSV 校验" + python3 "$ISSUE_VALIDATOR" "$ISSUE_CSV" + + echo ">>> [pretrial] 部署契约校验" + node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" + + echo ">>> [pretrial] Prisma schema 校验" + ( + cd "$SERVER_DIR" + npm run prisma:validate + ) + + echo ">>> [pretrial] smoke 数据审计脚本语法" + ( + cd "$SERVER_DIR" + node --check src/scripts/audit-smoke-data.js + node src/scripts/audit-smoke-data.js --help >/dev/null + ) + + if [[ -n "${DATABASE_URL:-}" ]]; then + echo ">>> [pretrial] smoke 数据足迹只读审计" + ( + cd "$SERVER_DIR" + npm run audit:smoke-data -- --format=text + ) + else + echo ">>> [pretrial] 跳过 smoke 数据足迹审计:当前 shell 未设置 DATABASE_URL" + fi + + echo ">>> [pretrial] UI 设计规则" + node "$ROOT_DIR/scripts/qa/check-ui-design-rules.mjs" + + echo ">>> [pretrial] client typecheck" + ( + cd "$CLIENT_DIR" + npm run typecheck + ) +} + +run_live_smoke() { + require_cmd curl + require_cmd node + require_cmd npm + + if ! has_live_smoke_credentials; then + echo ">>> [pretrial] 跳过 live smoke:缺少完整 LIVE_SMOKE_* 凭据" + return 0 + fi + + echo ">>> [pretrial] live smoke: ${PUBLIC_BASE_URL}" + LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live +} + +run_visual() { + require_cmd node + require_cmd npm + + if ! has_live_smoke_credentials; then + echo ">>> [pretrial] 跳过 R105 视觉自动化:缺少完整 LIVE_SMOKE_* 凭据" + return 0 + fi + + echo ">>> [pretrial] R105 公网视觉自动化: ${PUBLIC_BASE_URL}" + ( + cd "$CLIENT_DIR" + LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ + E2E_MODE=live \ + npx playwright test tests/e2e/r105-pretrial-visual.spec.ts --reporter=list + ) +} + +run_ocr() { + require_cmd node + require_cmd npm + + if [[ -z "${PLAYWRIGHT_OCR_LIVE_FILE:-}" ]]; then + echo ">>> [pretrial] 跳过真实 OCR:未设置 PLAYWRIGHT_OCR_LIVE_FILE" + return 0 + fi + + if [[ ! -f "$PLAYWRIGHT_OCR_LIVE_FILE" ]]; then + echo ">>> [pretrial] 跳过真实 OCR:文件不存在 ${PLAYWRIGHT_OCR_LIVE_FILE}" + return 0 + fi + + if ! has_live_ocr_credentials; then + echo ">>> [pretrial] 跳过真实 OCR:缺少 smoke 员工凭据" + return 0 + fi + + echo ">>> [pretrial] 真实 OCR 辅助复核: ${PUBLIC_BASE_URL}" + ( + cd "$CLIENT_DIR" + PLAYWRIGHT_MANUAL=1 \ + PLAYWRIGHT_OCR_LIVE=1 \ + E2E_BASE_URL="$PUBLIC_BASE_URL" \ + npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list + ) +} + +if [[ "$#" -eq 0 ]]; then + MODE="static" +elif [[ "$#" -eq 1 ]]; then + case "$1" in + --help|-h) + usage + exit 0 + ;; + --static) + MODE="static" + ;; + --live-smoke) + MODE="live-smoke" + ;; + --visual) + MODE="visual" + ;; + --ocr) + MODE="ocr" + ;; + --all) + MODE="all" + ;; + *) + usage >&2 + exit 1 + ;; + esac +else + usage >&2 + exit 1 +fi + +case "$MODE" in + static) + run_static + ;; + live-smoke) + run_live_smoke + ;; + visual) + run_visual + ;; + ocr) + run_ocr + ;; + all) + run_static + run_live_smoke + run_visual + run_ocr + ;; +esac + +echo "[pretrial][PASS] mode=${MODE}" diff --git a/server/package.json b/server/package.json index 5603edfd..a73063f2 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js", "job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js", "job:verification-once": "node src/scripts/run-verification-once.js", + "audit:smoke-data": "node src/scripts/audit-smoke-data.js", "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, diff --git a/server/src/scripts/audit-smoke-data.js b/server/src/scripts/audit-smoke-data.js new file mode 100644 index 00000000..777ff12e --- /dev/null +++ b/server/src/scripts/audit-smoke-data.js @@ -0,0 +1,277 @@ +require("dotenv/config"); + +const { createPrismaClient } = require("../prismaClient"); + +const DEFAULT_SMOKE_IDENTIFIERS = [ + "SMOKE001", + "SMOKE002", + "smoke-admin@hyinda.com", + "smoke-employee@hyinda.com", +]; + +function printHelp() { + process.stdout.write(`Usage: node src/scripts/audit-smoke-data.js [options] + +只读统计 smoke 账号在试运行环境中产生的数据足迹,不修改业务数据。 + +Options: + --format=text|json 输出格式,默认 text + --identifier= 追加一个 smoke 账号标识,可重复;匹配 employee_no/email/phone + --help 显示帮助 + +环境变量: + SMOKE_DATA_IDENTIFIERS 逗号或空白分隔的 smoke 标识,优先级低于 --identifier +`); +} + +function parseArgs(argv) { + const options = { + format: "text", + identifiers: [], + help: false, + }; + + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + + if (arg.startsWith("--format=")) { + options.format = arg.slice("--format=".length).trim() || "text"; + continue; + } + + if (arg.startsWith("--identifier=")) { + const value = arg.slice("--identifier=".length).trim(); + if (value) options.identifiers.push(value); + continue; + } + + throw new Error(`Unsupported argument: ${arg}`); + } + + if (!["text", "json"].includes(options.format)) { + throw new Error(`Unsupported --format: ${options.format}`); + } + + return options; +} + +function parseEnvIdentifiers() { + const raw = String(process.env.SMOKE_DATA_IDENTIFIERS ?? "").trim(); + if (!raw) { + return []; + } + + return raw + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function unique(values) { + return Array.from(new Set(values.filter(Boolean))); +} + +function buildEmployeeWhere(identifiers) { + return { + OR: identifiers.flatMap((value) => [ + { employee_no: value }, + { email: value }, + { phone: value }, + ]), + }; +} + +async function countByEmployee(prisma, modelName, fieldName, employeeIds) { + if (!employeeIds.length) { + return 0; + } + + return prisma[modelName].count({ + where: { + [fieldName]: { in: employeeIds }, + }, + }); +} + +async function auditSmokeData(prisma, identifiers) { + const employees = await prisma.employee.findMany({ + where: buildEmployeeWhere(identifiers), + select: { + id: true, + employee_no: true, + name: true, + email: true, + phone: true, + status: true, + archived_at: true, + }, + orderBy: [{ employee_no: "asc" }, { email: "asc" }], + }); + + const employeeIds = employees.map((item) => item.id); + const reimbursementIds = employeeIds.length + ? ( + await prisma.reimbursementForm.findMany({ + where: { + OR: [ + { owner_id: { in: employeeIds } }, + { submitter_id: { in: employeeIds } }, + ], + }, + select: { id: true }, + }) + ).map((item) => item.id) + : []; + + const invoiceRows = employeeIds.length + ? await prisma.invoicePool.findMany({ + where: { + OR: [ + { owner_employee_id: { in: employeeIds } }, + { uploaded_by_employee_id: { in: employeeIds } }, + ], + }, + select: { + id: true, + status: true, + ocr_result_id: true, + created_at: true, + }, + }) + : []; + + const workflowInstanceIds = employeeIds.length + ? ( + await prisma.workflowInstance.findMany({ + where: { + OR: [ + { submitter_id: { in: employeeIds } }, + { reimbursement_id: { in: reimbursementIds.length ? reimbursementIds : ["00000000-0000-0000-0000-000000000000"] } }, + ], + }, + select: { id: true }, + }) + ).map((item) => item.id) + : []; + + const invoiceStatusCounts = invoiceRows.reduce((acc, item) => { + acc[item.status] = (acc[item.status] ?? 0) + 1; + return acc; + }, {}); + const ocrResultIds = unique(invoiceRows.map((item) => item.ocr_result_id)); + + const counts = { + employees: employees.length, + authSessions: await countByEmployee(prisma, "authSession", "employee_id", employeeIds), + reimbursements: reimbursementIds.length, + reimbursementItems: reimbursementIds.length + ? await prisma.reimbursementItem.count({ where: { reimbursement_id: { in: reimbursementIds } } }) + : 0, + invoices: invoiceRows.length, + ocrResultsLinkedFromInvoices: ocrResultIds.length, + invoiceVerifications: invoiceRows.length + ? await prisma.invoiceVerificationRecord.count({ + where: { invoice_id: { in: invoiceRows.map((item) => item.id) } }, + }) + : 0, + leaveRequests: await countByEmployee(prisma, "leaveRequest", "owner_id", employeeIds), + workflowInstances: workflowInstanceIds.length, + workflowTasksAssigned: await countByEmployee(prisma, "workflowTask", "assignee_id", employeeIds), + workflowTasksCompleted: await countByEmployee(prisma, "workflowTask", "completed_by", employeeIds), + workflowHistory: await countByEmployee(prisma, "workflowHistory", "actor_id", employeeIds), + notifications: await countByEmployee(prisma, "notification", "recipient_employee_id", employeeIds), + auditLogs: await countByEmployee(prisma, "auditLog", "actor_id", employeeIds), + }; + + const latestInvoiceAt = invoiceRows + .map((item) => item.created_at) + .filter(Boolean) + .sort((left, right) => right.getTime() - left.getTime())[0] ?? null; + + return { + mode: "read_only", + generatedAt: new Date().toISOString(), + identifiers, + employees, + counts, + invoiceStatusCounts, + latestInvoiceAt: latestInvoiceAt ? latestInvoiceAt.toISOString() : null, + cleanupPolicy: { + recommendation: "本脚本仅审计。若要删除或归档 smoke 数据,请先备份并单独开 destructive cleanup Issue。", + safeScope: "仅允许处理 employee_no/email 明确匹配 smoke 标识的账号产生的数据。", + }, + }; +} + +function printTextReport(report) { + process.stdout.write("[smoke-data-audit] mode=read_only\n"); + process.stdout.write(`[smoke-data-audit] generatedAt=${report.generatedAt}\n`); + process.stdout.write(`[smoke-data-audit] identifiers=${report.identifiers.join(", ")}\n`); + + if (!report.employees.length) { + process.stdout.write("[smoke-data-audit] 未找到 smoke 员工账号。\n"); + return; + } + + process.stdout.write("\nEmployees:\n"); + for (const employee of report.employees) { + process.stdout.write( + `- ${employee.employee_no} ${employee.email} ${employee.name} status=${employee.status}${ + employee.archived_at ? " archived=true" : "" + }\n`, + ); + } + + process.stdout.write("\nCounts:\n"); + for (const [key, value] of Object.entries(report.counts)) { + process.stdout.write(`- ${key}: ${value}\n`); + } + + process.stdout.write("\nInvoice status:\n"); + const invoiceEntries = Object.entries(report.invoiceStatusCounts); + if (!invoiceEntries.length) { + process.stdout.write("- none\n"); + } else { + for (const [status, value] of invoiceEntries) { + process.stdout.write(`- ${status}: ${value}\n`); + } + } + + process.stdout.write(`\nLatest invoice: ${report.latestInvoiceAt ?? "none"}\n`); + process.stdout.write(`Recommendation: ${report.cleanupPolicy.recommendation}\n`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const identifiers = unique([ + ...options.identifiers, + ...parseEnvIdentifiers(), + ...DEFAULT_SMOKE_IDENTIFIERS, + ]); + + const { prisma, pool } = createPrismaClient(); + try { + const report = await auditSmokeData(prisma, identifiers); + if (options.format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + printTextReport(report); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +main().catch((error) => { + process.stderr.write(`[smoke-data-audit][FAIL] ${error?.message ?? error}\n`); + process.exitCode = 1; +});