import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import type { InvalidArticleSample, RunSummary, SampleResult } from "./types"; interface AggregateInput { startedAt: string; finishedAt: string; provider: string; model: string; baseURL: string; reportDir: string; samples: SampleResult[]; invalidSamples: InvalidArticleSample[]; } export function writeSampleResult(reportDir: string, result: SampleResult) { const sampleDir = join(reportDir, "samples", result.slug); mkdirSync(sampleDir, { recursive: true }); writeFileSync( join(sampleDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`, ); } export function readSampleResults(reportDir: string): SampleResult[] { const samplesDir = join(reportDir, "samples"); try { return readdirSync(samplesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => join(samplesDir, entry.name, "result.json")) .map( (filePath) => JSON.parse(readFileSync(filePath, "utf8")) as SampleResult, ) .sort((left, right) => left.file.localeCompare(right.file)); } catch { return []; } } export function aggregateSummary(input: AggregateInput): RunSummary { const passed = input.samples.filter((sample) => sample.status === "passed").length; const failed = input.samples.filter((sample) => sample.status === "failed").length; const skipped = input.samples.filter((sample) => sample.status === "skipped").length + input.invalidSamples.length; return { started_at: input.startedAt, finished_at: input.finishedAt, mode: "live", provider: input.provider, model: input.model, base_url: input.baseURL, report_dir: input.reportDir, totals: { passed, failed, skipped }, samples: input.samples, invalid_samples: input.invalidSamples, }; } export function writeSummaryFiles(reportDir: string, summary: RunSummary) { mkdirSync(reportDir, { recursive: true }); writeFileSync( join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, ); writeFileSync(join(reportDir, "summary.md"), renderSummaryMarkdown(summary)); } export function redactSensitiveText( text: string, secrets: Array, ) { let redacted = text .replace(/(x-api-key:\s*)([^\s]+)/gi, "$1[REDACTED]") .replace(/(authorization:\s*bearer\s+)([^\s]+)/gi, "$1[REDACTED]") .replace( /((?:DEEPSEEK|OPENAI|API)_?[A-Z_]*KEY=)"?([^"\s]+)"?/g, '$1"[REDACTED]"', ); for (const secret of secrets) { if (!secret) continue; redacted = redacted.split(secret).join("[REDACTED]"); } return redacted; } function renderSummaryMarkdown(summary: RunSummary) { const lines = [ "# GEO 样例文章 E2E 测试报告", "", `- 模式:${summary.mode}`, `- Provider:${summary.provider}`, `- Model:${summary.model}`, `- Base URL:${summary.base_url}`, `- 报告目录:${summary.report_dir}`, `- 结果:通过 ${summary.totals.passed},失败 ${summary.totals.failed},跳过 ${summary.totals.skipped}`, "", "| 样例 | 状态 | QA | 耗时 | 失败分类 |", "| --- | --- | --- | ---: | --- |", ...summary.samples .map((sample) => [ sample.name, sample.status, sample.qa_status ?? "", `${Math.round(sample.duration_ms / 1000)}s`, sample.failure_category ?? "", ].join(" | "), ) .map((row) => `| ${row} |`), ]; if (summary.invalid_samples.length > 0) { lines.push("", "## 无效样例", ""); for (const sample of summary.invalid_samples) { lines.push(`- ${sample.fileName}: ${sample.reason}`); } } lines.push(""); return lines.join("\n"); } export function relativeArtifactPath(reportDir: string, artifactPath: string) { return relative(reportDir, artifactPath); }