R105/A2-A5 补强试运行自动化验收

这个提交包含在:
2026-05-07 12:52:18 +08:00
父节点 d99cbf8491
当前提交 86975dd2a4
共修改 9 个文件,包含 679 行新增和 4 行删除
@@ -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`
@@ -197,7 +197,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
return ( return (
<nav className="space-y-5"> <nav className="space-y-5">
{renderSection("主导航", "优先处理今日工作与高频协同。", sections.main, onNavigate)} {renderSection("主导航", "今日工作与高频协同。", sections.main, onNavigate)}
{renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)} {renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)}
{renderBackupSection(sections.backup, onNavigate)} {renderBackupSection(sections.backup, onNavigate)}
</nav> </nav>
+1 -1
查看文件
@@ -11,7 +11,7 @@ export function Sidebar() {
<span className="size-2 rounded-full bg-primary" /> <span className="size-2 rounded-full bg-primary" />
今日工作台 今日工作台
</div> </div>
<div className="mt-2 leading-6">先处理待办和团队状态,再进入具体模块。</div> <div className="mt-2 leading-6">待处理、团队状态和常用入口。</div>
</div> </div>
</div> </div>
+1 -1
查看文件
@@ -176,7 +176,7 @@ export function ProjectsPage() {
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} /> <MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" /> <MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="建议先处理延期事项" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} /> <MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} /> <MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</section> </section>
@@ -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 });
});
});
+1 -1
查看文件
@@ -14,7 +14,7 @@ const scanRoots = [
"client/src/pages", "client/src/pages",
]; ];
const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]); const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]);
const forbiddenCopy = ["先处理这几件事", "清爽"]; const forbiddenCopy = ["先处理", "清爽"];
const findings = []; const findings = [];
function toPosix(value) { function toPosix(value) {
+212
查看文件
@@ -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 <<USAGE
Usage: bash scripts/qa/run-pretrial-readiness.sh [--static|--live-smoke|--visual|--ocr|--all]
R105 真实试运行前门禁。
默认:
--static CSV 校验、部署契约、Prisma、UI 规则、audit-smoke-data 语法、client typecheck
可选公网项:
--live-smoke 需要完整 LIVE_SMOKE_*,执行 bash scripts/qa/run-web-gates.sh smoke-live
--visual 需要完整 LIVE_SMOKE_*,执行 R105 公网视觉 Playwright
--ocr 需要 PLAYWRIGHT_OCR_LIVE_FILE 和 smoke 员工凭据,执行真实 OCR 辅助复核
--all static + live-smoke + visual + ocr
本脚本不会打印 LIVE_SMOKE_* 密码或第三方 secret。
USAGE
}
require_cmd() {
command -v "$1" >/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}"
+1
查看文件
@@ -24,6 +24,7 @@
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js", "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:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
"job:verification-once": "node src/scripts/run-verification-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:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
+277
查看文件
@@ -0,0 +1,277 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const DEFAULT_SMOKE_IDENTIFIERS = [
"SMOKE001",
"SMOKE002",
"[email protected]",
"[email protected]",
];
function printHelp() {
process.stdout.write(`Usage: node src/scripts/audit-smoke-data.js [options]
只读统计 smoke 账号在试运行环境中产生的数据足迹,不修改业务数据。
Options:
--format=text|json 输出格式,默认 text
--identifier=<value> 追加一个 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;
});