/** * Generate or validate self-test-report.md. * * CLI (no subcommands — flags): * report-tool.ts --task-subdir ... --app-metadata ... --hap ... --device ... \ * --suite ... --out ... [--validate] * Render `self-test-report.md` from a task_/ directory, optionally * re-validating the output in-process. * report-tool.ts --check-only * Check an existing report conforms to the required layout. * Exit 0 = PASSED, exit 1 = FAILED. * * The emitted markdown is a FROZEN contract parsed by the * hmos-integration-test skill and validateReport below — template strings are * verbatim and must not drift. */ import fs from 'node:fs'; import path from 'node:path'; // Inlined from src/utils/regex.ts so this file is self-contained — no hop into src/. function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Strip a leading UTF-8 BOM (0xFEFF) from file contents before JSON.parse — // users (or PowerShell `Set-Content -Encoding utf8`) often produce BOM-prefixed // JSON files which would otherwise throw "Unexpected token" in JSON.parse. function readJsonSync(filePath: string): unknown { let text = fs.readFileSync(filePath, 'utf-8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); return JSON.parse(text); } const PRE_PREFIX = '[PRE] '; export interface Case { case_name: string; status: string; duration_seconds?: number; report_dir?: string; test_steps?: string; reason?: string; _judgment?: Record; _last_thinking?: string; [k: string]: unknown; } export interface Totals { preTotal: number; regularTotal: number; prePass: number; regularPass: number; pass: number; fail: number; unknown: number; passRate: string; regularPassRate: string; } /** Name with the `[PRE] ` prefix stripped (idempotent for non-pre names). */ function stripPre(name: string): string { return name.startsWith(PRE_PREFIX) ? name.slice(PRE_PREFIX.length) : name; } // ============================================================================= // Parsers // ============================================================================= /** Parse ... from a task_end event. Returns header→body map. */ export function parseJudgmentBlock(content: string): Record { const m = content.match(/([\s\S]*?)<\/judgment>/s); if (!m) return {}; const parts = m[1].trim().split(/\*\*([一-鿿A-Za-z]+)\*\*\s*[::]\s*/); const sections: Record = {}; for (let i = 1; i < parts.length; i += 2) { sections[parts[i].trim()] = (i + 1 < parts.length ? parts[i + 1] : '').trim(); } return sections; } /** Parse `动作:... -> ...\n预期结果:...` into (action_list, expected_text). */ export function parseTestSteps(testSteps: string): [string[], string] { const actionsMatch = testSteps.match(/动作[::]\s*(.+?)(?=\n\s*预期结果[::]|$)/s); const expectedMatch = testSteps.match(/预期结果[::]\s*(.+)/s); const actionList = actionsMatch ? actionsMatch[1] .trim() .split(/\s*->\s*|\s*→\s*/) .map(s => s.trim()) .filter(Boolean) : testSteps.trim() ? [testSteps.trim()] : []; return [actionList, expectedMatch ? expectedMatch[1].trim() : '']; } /** Locate ///.json (the structured run log). */ export function findLatestTaskJson(reportDir: string): string | null { if (!fs.existsSync(reportDir)) return null; const datePat = /^\d{4}-\d{2}-\d{2}$/; const timePat = /^\d{2}-\d{2}-\d{2}$/; // Stay robust (return null) but // surface the cause on stderr so real FS problems aren't silently hidden. const subdirs = (dir: string, pat: RegExp): string[] | null => { try { return fs .readdirSync(dir) .filter(n => pat.test(n) && fs.statSync(path.join(dir, n)).isDirectory()) .sort(); } catch (e) { console.error(`WARN: cannot read dir ${dir}: ${e instanceof Error ? e.message : e}`); return null; } }; const dateDirs = subdirs(reportDir, datePat); if (!dateDirs || dateDirs.length === 0) return null; for (let di = dateDirs.length - 1; di >= 0; di--) { const dateDirPath = path.join(reportDir, dateDirs[di]); const timeDirs = subdirs(dateDirPath, timePat); if (!timeDirs) continue; for (let ti = timeDirs.length - 1; ti >= 0; ti--) { const candidate = path.join(dateDirPath, timeDirs[ti], `${timeDirs[ti]}.json`); if (fs.existsSync(candidate)) return candidate; } } return null; } interface Event { event_type?: unknown; content?: unknown; } /** Return content of the last event with the given type, or ''. */ function lastEventContent(events: Event[], type: string): string { for (let i = events.length - 1; i >= 0; i--) { if (events[i]?.event_type === type) return String(events[i].content ?? '').trim(); } return ''; } /** Load the judgment block + last thinking for one case. Returns [judgment, lastThinking]. */ export function loadCaseJudgment(reportDir: string): [Record, string] { const jsonPath = findLatestTaskJson(reportDir); if (jsonPath === null) return [{}, '']; let data: unknown; try { data = readJsonSync(jsonPath); } catch { return [{}, '']; } const raw = (data as { events?: unknown })?.events; const events: Event[] = Array.isArray(raw) ? (raw as Event[]) : []; if (events.length === 0) return [{}, '']; const taskEnd = lastEventContent(events, 'task_end'); return [taskEnd ? parseJudgmentBlock(taskEnd) : {}, lastEventContent(events, 'planner_thinking')]; } // ============================================================================= // Render helpers // ============================================================================= /** Compact a multi-line judgment value into a single line. */ function oneLine(s: string): string { return s.replace(/\s+/g, ' ').trim(); } /** Truncate `s` to `max` chars, appending an ellipsis when cut. */ function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + '…' : s; } /** Render `- key: value` under 'AutoTest 详情' (single-line, else nested lines). */ function emitJudgmentBullet(key: string, value: string): string[] { const text = (value ?? '').trim(); const rawLines = text.split(/\r\n|\r|\n/).filter(ln => ln.trim()); if (rawLines.length <= 1) { return [` - ${key}: ${oneLine(text) || '(空)'}`]; } return [` - ${key}:`, ...rawLines.map(ln => ` ${ln.replace(/\s+$/, '')}`)]; } // ============================================================================= // Renderers // ============================================================================= /** Render the `## 测试概览` block. */ function renderOverview( suite: string, timeRange: string, device: string, appName: string, bundleName: string, hap: string, totals: Totals, ): string { const total = totals.preTotal + totals.regularTotal; const notPassed = totals.fail + totals.unknown; return [ '## 测试概览', '', `- **测试套件**: ${suite}`, `- **测试时间**: ${timeRange}`, `- **设备**: ${device}`, `- **应用**: ${appName} (${bundleName})`, `- **HAP**: ${hap}`, `- **总用例数**: ${total}(前置 ${totals.preTotal} + 常规 ${totals.regularTotal})`, `- **通过**: ${totals.pass}(前置 ${totals.prePass} / 常规 ${totals.regularPass})`, `- **失败**: ${notPassed}(FAIL ${totals.fail} + UNKNOWN ${totals.unknown})`, `- **常规通过率**: ${totals.regularPassRate}(仅功能场景,反映本次需求质量)`, `- **含前置通过率**: ${totals.passRate}(仅供整体参考;前置用例属于数据/环境准备,与本次需求功能无关)`, '', ].join('\n'); } /** Derive the `操作执行` line from status + judgment. */ function opExecLine(status: string, judgment: Record, reason: string): string { if (status === 'PASS') { const expected = (judgment['预期验证'] || '').trim(); return expected ? `成功 — ${expected}` : '成功 — AutoTest 判定通过'; } if (status === 'FAIL') { const detail = (judgment['Bug发现'] || '').trim() || (judgment['状态确认'] || '').trim(); if (detail) return `失败 — ${(detail.split(/\r\n|\r|\n/)[0] ?? '').slice(0, 200)}`; return `失败 — ${reason || 'AutoTest 判定不通过'}`; } return `未完成 — ${reason || 'AutoTest 未能给出确定结论'}`; } /** Render a single Pre N / Case N block. */ export function renderCaseBlock(idx: number, prefix: string, case_: Case): string { const title = prefix === 'Pre' ? stripPre(case_.case_name) : case_.case_name; const status = case_.status; const duration = case_.duration_seconds ?? 0; const reportDir = case_.report_dir ?? ''; const reason = (case_.reason || '').trim(); const judgment = case_._judgment || {}; const lastThinking = case_._last_thinking || ''; const [actions, expected] = parseTestSteps(case_.test_steps ?? ''); const lines: string[] = [`### ${prefix} ${idx}: ${title}`, '']; lines.push(`- **执行结果**: ${status}`); lines.push('- **操作步骤**:'); if (actions.length > 0) { actions.forEach((a, i) => lines.push(` ${i + 1}. ${a}`)); } else { lines.push(' 1. (未提供动作步骤)'); } lines.push(`- **期望结果**: ${expected || '(未提供期望结果)'}`); lines.push(`- **操作执行**: ${opExecLine(status, judgment, reason)}`); lines.push(`- **AutoTest 任务路径**: \`${reportDir}\``); lines.push('- **AutoTest 详情**:'); lines.push(` - AutoTest 结果: ${status}`); if (Object.keys(judgment).length > 0) { // 失败原因 is the downstream-facing alias of Bug发现 — emit it (not Bug发现) so // the validator's literal check passes without duplication. const seen = new Set(['Bug发现']); for (const key of ['预期验证', '历史回顾', '状态确认', '判定结果']) { if (key in judgment) { lines.push(...emitJudgmentBullet(key, judgment[key])); seen.add(key); } } for (const [key, value] of Object.entries(judgment)) { if (!seen.has(key)) lines.push(...emitJudgmentBullet(key, value)); } if (status === 'PASS') { lines.push(' - 失败原因: 无'); } else { lines.push(...emitJudgmentBullet('失败原因', judgment['Bug发现'] || reason || '未知')); } } else { lines.push(` - 失败原因: ${reason || '未知'}`); if (lastThinking) { lines.push(` - 末态思考摘要: ${truncate(lastThinking.replace(/\n/g, ' '), 400)}`); } } lines.push(` - 耗时: ${duration} 秒`); if (status === 'UNKNOWN') { lines.push('- **⚠️ UNKNOWN 处理规则(fixer 必读)**:'); lines.push( ' - 本用例 AutoTest agent **未到达验证步骤**(步数预算耗尽 / 超时 / 异常终止),真实通过状态未知,**不等价于 FAIL**。', ); lines.push( ' - 即使白盒审查在相邻代码中发现瑕疵,也**不得**把本用例算作 confirmed → 已修复。原始 UNKNOWN 的根因(agent 超时)不会因任何代码改动消失,必须在 AutoTest 端加大步数上限/人工复测后才能定论。', ); lines.push( ' - 处理建议:在 fixer 白盒审查阶段将本用例归类为 `unknown_no_verdict` —— 记录「待重跑确认」,**不计入本轮修复成果**,也不消耗 fixer 的有效尝试预算。', ); } lines.push(''); return lines.join('\n'); } /** Render the `## 测试总结` block. */ function renderSummary(cases: Case[], totals: Totals): string { const total = totals.preTotal + totals.regularTotal; const notPassed = totals.fail + totals.unknown; const lines = [ '## 测试总结', '', `- **总计用例**: ${total}(PRE ${totals.preTotal} + Regular ${totals.regularTotal})`, `- **常规用例(功能场景)**: ${totals.regularPass}/${totals.regularTotal} 通过,**通过率 ${totals.regularPassRate}**(反映本次需求质量)`, `- **前置用例(数据/环境准备)**: ${totals.prePass}/${totals.preTotal} 通过(与本次功能需求无关,仅作环境就绪信号)`, `- **总计通过**: ${totals.pass}`, `- **总计未通过**: ${notPassed}(FAIL ${totals.fail} + UNKNOWN ${totals.unknown})`, `- **含前置通过率**: ${totals.passRate}(不作为功能质量指标)`, '', ]; const notPassedCases = cases.filter(c => c.status !== 'PASS'); if (notPassedCases.length > 0) { lines.push('### 未通过用例列表', ''); lines.push('| # | 类别 | 用例描述 | 失败类型 | 失败原因 |'); lines.push('|---|------|---------|---------|---------|'); notPassedCases.forEach((c, i) => { const category = c.case_name.startsWith(PRE_PREFIX) ? '前置' : '常规'; const bug = ((c._judgment ?? {})['Bug发现'] || '').trim(); const failReason = truncate(oneLine(bug || (c.reason || '').trim() || '未知'), 200); lines.push(`| ${i + 1} | ${category} | ${stripPre(c.case_name)} | ${c.status} | ${failReason} |`); }); lines.push(''); } lines.push('### 建议', ''); const suggestions = buildSuggestions(cases); for (const s of suggestions) lines.push(`- ${s}`); if (suggestions.length === 0) lines.push('- 全部用例通过,无需进一步动作。'); lines.push(''); return lines.join('\n'); } // ============================================================================= // Suggestions // ============================================================================= /** Failure-text keywords hinting a regular case failed on unmet preconditions * rather than a real defect — i.e. a cascade from a failed pre-case. */ const CASCADE_PATTERNS: readonly string[] = [ '测试环境', '环境条件', '环境异常', '前置', '没有音乐', '无音乐', '没有文件', '无文件', '无可用', '库为空', '歌曲库为空', '数据缺失', '数据不足', 'no files', 'no music', 'library is empty', ]; /** True if the case's failure text (judgment + last thinking) matches any cascade keyword. */ function isCascadeFailure(case_: Case): boolean { const j = case_._judgment ?? {}; const text = [ case_.reason ?? '', j['Bug发现'] ?? '', j['状态确认'] ?? '', j['历史回顾'] ?? '', case_._last_thinking ?? '', ] .join(' ') .toLowerCase(); return CASCADE_PATTERNS.some(p => text.includes(p.toLowerCase())); } /** Join stripped, per-item-truncated case names for suggestion text. */ function names(cases: Case[], max: number): string { return cases.map(c => stripPre(c.case_name).slice(0, max)).join(', '); } /** Compose templated suggestions based on failure categories — no LLM needed. * PRE fail → env/data fix; cascade → warn PRE-caused; UNKNOWN → not "fixed"; * real regular FAIL → route to self-test-fixer. */ export function buildSuggestions(cases: Case[]): string[] { const out: string[] = []; const preFails = cases.filter(c => c.case_name.startsWith(PRE_PREFIX) && c.status !== 'PASS'); const regNonPass = cases.filter(c => !c.case_name.startsWith(PRE_PREFIX) && c.status !== 'PASS'); const regUnknowns = regNonPass.filter(c => c.status === 'UNKNOWN'); const regFails = regNonPass.filter(c => c.status === 'FAIL'); // Cascade detection only makes sense when pre-cases also failed. const cascadeCases = preFails.length > 0 ? regNonPass.filter(isCascadeFailure) : []; if (preFails.length > 0) { out.push( `**前置用例失败**(${preFails.length} 条:${names(preFails, 30)}):**前置用例不是本次需求功能的测试**,它们只是为常规用例准备数据/环境(如导入媒体、授予权限、跳过引导)。失败 90% 以上是测试环境/数据问题(缺素材、设备未授权、文件选择器交互异常等),**不应当作应用缺陷处理**。建议:人工补齐前置数据后重跑受影响的常规用例;只有当白盒审查确认前置流程触发的是真实代码 Bug 时,才进入 self-test-fixer 修复流。`, ); } if (cascadeCases.length > 0) { out.push( `**⚠️ ${cascadeCases.length} 条常规用例疑似前置连带失败**(${names(cascadeCases, 50)}):失败原因中出现「测试环境/环境条件/没有音乐文件/前置不满足」等关键词,说明这些用例 **可能不是独立的功能 Bug,而是 PRE 失败的传导**——在前置数据未准备好的前提下,这些用例根本没机会跑到验证步骤。**修复优先级**:先补齐前置 → 重跑这些用例 → 仍 FAIL 才认定为真实应用缺陷再进入修复流。self-test-fixer 收到此类 case 应在白盒审查时严格甄别,避免给「环境不就绪」打补丁。`, ); } if (regUnknowns.length > 0) { out.push( `**${regUnknowns.length} 条 UNKNOWN**(${names(regUnknowns, 50)}):AutoTest agent 因步数预算耗尽 / 超时未达验证阶段,**这些用例的真实通过状态未知**,与 FAIL 性质不同。**fixer agent 不应把 UNKNOWN 算作「已修复」**——即使白盒能找出顺手可修的瑕疵,原始 UNKNOWN 的根因(agent 超时)不会因代码改动消失,必须在 AutoTest 端加大步数上限或人工复测后才能定论。建议:将 UNKNOWN 案例从本轮修复目标中排除,仅作「待重跑候选」。`, ); } const realRegFails = regFails.filter(c => !cascadeCases.includes(c)); if (realRegFails.length > 0) { out.push( `**${realRegFails.length} 条疑似真实常规 FAIL**(已排除前置连带):交由 \`self-test-fixer\` agent 白盒审查后修复,或人工复核失败用例对应的 HarmonyOS 代码与 Android 参考实现。`, ); } else if (regFails.length > 0) { out.push( '本次所有常规 FAIL 均疑似前置连带,**预计无需修改应用代码**。请优先把前置流程跑通,再用同一批用例复跑确认。', ); } return out; } // ============================================================================= // Validator // ============================================================================= const REQUIRED_OVERVIEW_FIELDS = [ '测试套件', '测试时间', '设备', '应用', 'HAP', '总用例数', '通过', '失败', ]; const REQUIRED_CASE_FIELDS = [ '执行结果', '操作步骤', '期望结果', 'AutoTest 任务路径', 'AutoTest 详情', ]; const REQUIRED_SECTIONS = ['测试概览', '前置用例', '用例详情', '测试总结']; const CASE_SECTIONS: Array<[string, string]> = [ ['前置用例', 'Pre'], ['用例详情', 'Case'], ]; /** Return the body of `## ` up to the next `## ` (exclusive), or null. */ function extractMdSection(text: string, sectionTitle: string): string | null { const m = text.match( new RegExp(`##\\s*${escapeRegExp(sectionTitle)}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`), ); return m ? m[1] : null; } /** Split a case section into (heading, body) blocks by `### ` headings. */ function splitCaseBlocks(sectionBody: string): Array<[string, string]> { return sectionBody .split(/^###\s+/m) .slice(1) .map(chunk => { const nl = chunk.indexOf('\n'); return nl === -1 ? ([chunk.trim(), ''] as [string, string]) : ([chunk.slice(0, nl).trim(), chunk.slice(nl + 1)] as [string, string]); }); } /** Validate one case section's headings. Returns {blocks, errors}. */ function validateCaseSection( sectionBody: string, sectionTitle: string, expectedPrefix: string, ): { blocks: Array<[string, string]>; errors: string[] } { const errors: string[] = []; const blocks = splitCaseBlocks(sectionBody); if (blocks.length === 0 && expectedPrefix === 'Case') { errors.push( `Section '## ${sectionTitle}': no '### Case :' blocks found — report may be using a table-only format without per-case detail sections`, ); } const pattern = new RegExp(`^${escapeRegExp(expectedPrefix)}\\s+(\\d+):\\s`); const indices: number[] = []; for (const [heading] of blocks) { const m = heading.match(pattern); if (!m) { errors.push( `Section '## ${sectionTitle}': heading does NOT match \`### ${expectedPrefix} : \` — got \`### ${heading}\``, ); } else { indices.push(parseInt(m[1], 10)); } } if (indices.length > 0) { const expected = Array.from({ length: indices.length }, (_, k) => k + 1); if (indices.join(',') !== expected.join(',')) { errors.push( `Section '## ${sectionTitle}': ${expectedPrefix} indices not contiguous from 1; expected [${expected.join(', ')}], got [${indices.join(', ')}]`, ); } } return { blocks, errors }; } /** Return a list of validation errors for self-test-report.md. Empty == PASSED. */ export function validateReport(reportPath: string): string[] { if (!fs.existsSync(reportPath)) return [`File not found: ${reportPath}`]; const text = fs.readFileSync(reportPath, 'utf-8'); const errors: string[] = []; for (const section of REQUIRED_SECTIONS) { if (!text.includes(section)) errors.push(`Missing section: '${section}'`); } const overview = extractMdSection(text, '测试概览'); if (overview === null) { errors.push("Cannot locate '## 测试概览' section"); } else { for (const field of REQUIRED_OVERVIEW_FIELDS) { if (!overview.includes(field)) errors.push(`Missing overview field: '${field}'`); } } const allBlocks: Array<[string, string, string]> = []; // (sectionTitle, heading, body) for (const [sectionTitle, expectedPrefix] of CASE_SECTIONS) { const body = extractMdSection(text, sectionTitle); if (body === null) continue; const { blocks, errors: sectionErrors } = validateCaseSection(body, sectionTitle, expectedPrefix); errors.push(...sectionErrors); for (const [heading, blockBody] of blocks) { allBlocks.push([sectionTitle, heading, blockBody]); } } const label = (heading: string): string => truncate(heading, 60); for (const [sectionTitle, heading, body] of allBlocks) { const missing = REQUIRED_CASE_FIELDS.filter(f => !body.includes(f)); if (missing.length > 0) { errors.push( `Section '## ${sectionTitle}', block \`### ${label(heading)}\`: missing fields: [${missing .map(f => `'${f}'`) .join(', ')}]`, ); } } if (text.match(/\*\*失败\*\*:\s*([1-9])/)) { if (!text.includes('未通过用例列表')) { errors.push("Report has failures but missing '未通过用例列表' section"); } for (const [sectionTitle, heading, body] of allBlocks) { if (/\*\*执行结果\*\*:\s*FAIL/i.test(body)) { const detailParts = body.split('AutoTest 详情'); const detailPart = detailParts.length > 1 ? detailParts[1] : body; if (!detailPart.includes('失败原因') && !detailPart.includes('失败')) { errors.push( `Section '## ${sectionTitle}', block \`### ${label(heading)}\`: FAIL but missing failure reason in 'AutoTest 详情'`, ); } } } } return errors; } // ============================================================================= // Pipeline (generate) // ============================================================================= class FileNotFoundError extends Error { constructor(message: string) { super(message); this.name = 'FileNotFoundError'; } } /** Shape of `summary.json` written by AutoTest.batch (all fields optional). */ interface SummaryJson { pass_count?: number; fail_count?: number; unknown_count?: number; pass_rate?: string; start_time?: string; end_time?: string; } /** Read task_results.jsonl and hydrate each case with its judgment + thinking. */ function loadCases(jsonlPath: string): Case[] { const cases: Case[] = []; let raw = fs.readFileSync(jsonlPath, 'utf-8'); if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip BOM for (const line of raw.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; const case_: Case = JSON.parse(trimmed); const [judgment, lastThinking] = case_.report_dir ? loadCaseJudgment(case_.report_dir) : [{}, '']; case_._judgment = judgment; case_._last_thinking = lastThinking; cases.push(case_); } return cases; } /** Compose the complete self-test-report.md text from task_<ts>/ data. */ export function renderReport( taskSubdir: string, appMetadata: string, hap: string, device: string, suite: string, ): string { const summaryPath = path.join(taskSubdir, 'summary.json'); const jsonlPath = path.join(taskSubdir, 'task_results.jsonl'); for (const p of [summaryPath, jsonlPath]) { if (!fs.existsSync(p)) throw new FileNotFoundError(`missing file in task-subdir: ${p}`); } const summary: SummaryJson = readJsonSync(summaryPath) as SummaryJson; const cases = loadCases(jsonlPath); const preCases = cases.filter(c => c.case_name.startsWith(PRE_PREFIX)); const regCases = cases.filter(c => !c.case_name.startsWith(PRE_PREFIX)); const count = (seq: Case[], status: string): number => seq.filter(c => c.status === status).length; const regularPass = count(regCases, 'PASS'); const totals: Totals = { preTotal: preCases.length, regularTotal: regCases.length, prePass: count(preCases, 'PASS'), regularPass, pass: summary.pass_count ?? count(cases, 'PASS'), fail: summary.fail_count ?? count(cases, 'FAIL'), unknown: summary.unknown_count ?? count(cases, 'UNKNOWN'), passRate: summary.pass_rate ?? '0%', regularPassRate: regCases.length > 0 ? `${((regularPass / regCases.length) * 100).toFixed(2)}%` : 'N/A', }; const meta: { bundle_name?: string; app_name?: string } = readJsonSync(appMetadata) as { bundle_name?: string; app_name?: string }; const startTime = summary.start_time ?? ''; const endTime = summary.end_time ?? ''; const timeRange = startTime && endTime ? `${startTime} ~ ${endTime}` : startTime || '未知'; const parts: string[] = [ '# Self-Test 测试报告', '', renderOverview( suite, timeRange, device, meta.app_name ?? '', meta.bundle_name ?? '', path.basename(hap), totals, ), '---', '', '## 前置用例', '', ]; if (preCases.length > 0) { parts.push( '> **前置用例是为常规用例准备数据/环境的设置脚本(来自 `pre_test_case.md`),**不属于本次需求功能本身**。此处的 PASS/FAIL 仅反映测试前置条件是否就绪:FAIL 通常意味着测试环境问题(如缺少素材、权限未授予、首启引导未跳过),并非应用功能缺陷。但需注意——前置失败可能让后续依赖该数据的常规用例无效,判读常规结果时应将受影响的用例排除或在补齐前置后重跑。', ); parts.push(''); preCases.forEach((c, i) => parts.push(renderCaseBlock(i + 1, 'Pre', c))); } else { parts.push('_无前置用例_', ''); } parts.push('---', '', '## 用例详情', ''); regCases.forEach((c, i) => parts.push(renderCaseBlock(i + 1, 'Case', c))); parts.push('---', '', renderSummary(cases, totals)); return parts.join('\n'); } // ============================================================================= // CLI // ============================================================================= interface GenerateOpts { taskSubdir: string; appMetadata: string; hap: string; device: string; suite: string; out: string; validate?: boolean; } /** Print validation result and return exit code (0 = pass, 1 = fail). */ function printValidation(errors: string[], reportPath: string): number { if (errors.length === 0) { console.log(`VALIDATION PASSED: ${reportPath}`); return 0; } console.log(`VALIDATION FAILED: ${errors.length} issue(s) in ${reportPath}`); for (const e of errors) console.log(` - ${e}`); return 1; } /** Generate mode: render the report and optionally re-validate. */ function cmdGenerate(opts: GenerateOpts): void { const taskSubdir = path.resolve(opts.taskSubdir); if (!fs.existsSync(taskSubdir)) { console.error(`ERROR: task-subdir not found: ${taskSubdir}`); process.exit(2); } let reportText: string; try { reportText = renderReport(taskSubdir, opts.appMetadata, opts.hap, opts.device, opts.suite); } catch (e) { if (e instanceof FileNotFoundError) { console.error(`ERROR: ${e.message}`); process.exit(2); } throw e; } const outPath = path.resolve(opts.out); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, reportText, 'utf-8'); console.log(`Generated: ${outPath}`); if (opts.validate) { process.exit(printValidation(validateReport(outPath), outPath)); } } /** Validate mode (--check-only): validate an existing report. */ function cmdValidate(reportPath: string): void { const resolved = path.resolve(reportPath); process.exit(printValidation(validateReport(resolved), resolved)); } // ---- CLI ---- const args = process.argv.slice(2); if (args.includes('-h') || args.includes('--help')) { console.log(`Usage: report-tool.ts [options] Options: --task-subdir <path> task_<ts>/ directory with summary.json + task_results.jsonl --app-metadata <path> app-metadata.json path --hap <path> HAP file path --device <id> device identifier --suite <name> test suite title --out <path> output self-test-report.md path --validate re-validate after writing --check-only <path> validate an existing report without generating `); process.exit(0); } if (args.includes('--check-only')) { // Validate mode const idx = args.indexOf('--check-only'); const filepath = args[idx + 1]; if (!filepath) { console.error('Usage: report-tool.ts --check-only <path>'); process.exit(1); } cmdValidate(filepath); } else { // Generate mode (default) const opts: GenerateOpts = { taskSubdir: '', appMetadata: '', hap: '', device: '', suite: '', out: '' }; for (let i = 0; i < args.length; i++) { const a = args[i]; const next = (): string => { if (i + 1 >= args.length) { console.error(`Missing value for ${a}`); process.exit(1); } return args[++i]; }; switch (a) { case '--task-subdir': opts.taskSubdir = next(); break; case '--app-metadata': opts.appMetadata = next(); break; case '--hap': opts.hap = next(); break; case '--device': opts.device = next(); break; case '--suite': opts.suite = next(); break; case '--out': opts.out = next(); break; case '--validate': opts.validate = true; break; } } for (const req of ['taskSubdir', 'appMetadata', 'hap', 'device', 'suite', 'out'] as const) { if (!opts[req]) { console.error(`Missing required option: --${req.replace(/([A-Z])/g, '-$1').toLowerCase()}`); process.exit(1); } } cmdGenerate(opts); }