diff --git a/.github/review-prompt.md b/.github/review-prompt.md index ed511dc1..4f91e5ff 100644 --- a/.github/review-prompt.md +++ b/.github/review-prompt.md @@ -56,7 +56,8 @@ Use only the review contract in this file plus the generated scope and packet fi - Use `approved_with_notes` when only non-blocking follow-ups remain. - Use `changes_requested` when any blocking issue remains. - Fill the structured fields only. The renderer owns the markdown layout. -- Keep `summary` to plain prose only. Do not include the PR title, a separate verdict line, markdown tables, file inventories, or custom section headings there. +- Keep `summary` to plain prose only, ideally 2-4 sentences. Do not include the PR title, a separate verdict line, markdown tables, file inventories, or custom section headings there. +- Keep `overallRationale` to 1 sentence. - Keep `what`, `why`, and `fix` concise plain text. Do not emit headings, tables, or fenced code blocks inside those fields. - Use `snippets` only when a short literal excerpt materially clarifies a finding. Keep each snippet under 20 lines, and do not include markdown fences in `code`. - Use `securityChecklist` for concise review rows about security-sensitive checks. Provide at least 1 row, and use 2-5 when possible. `status` = `pass` | `fail` | `na`. diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 7ce457f0..bb3c2d0f 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -196,12 +196,12 @@ jobs: # GLM API environment for model routing env: ANTHROPIC_BASE_URL: https://api.z.ai/api/anthropic - REVIEW_MODEL: glm-5.1 + REVIEW_MODEL: glm-5-turbo ANTHROPIC_AUTH_TOKEN: ${{ secrets.GLM_API_KEY }} - ANTHROPIC_MODEL: glm-5.1 - ANTHROPIC_DEFAULT_OPUS_MODEL: glm-5.1 - ANTHROPIC_DEFAULT_SONNET_MODEL: glm-5.1 - ANTHROPIC_DEFAULT_HAIKU_MODEL: GLM-4.7-FlashX + ANTHROPIC_MODEL: glm-5-turbo + ANTHROPIC_DEFAULT_OPUS_MODEL: glm-5-turbo + ANTHROPIC_DEFAULT_SONNET_MODEL: glm-5-turbo + ANTHROPIC_DEFAULT_HAIKU_MODEL: glm-5-turbo DISABLE_BUG_COMMAND: '1' DISABLE_ERROR_REPORTING: '1' DISABLE_TELEMETRY: '1' diff --git a/scripts/github/normalize-ai-review-output.mjs b/scripts/github/normalize-ai-review-output.mjs index 9be73e21..06d2436c 100644 --- a/scripts/github/normalize-ai-review-output.mjs +++ b/scripts/github/normalize-ai-review-output.mjs @@ -14,6 +14,11 @@ const SEVERITY_HEADERS = { medium: '### 🟡 Medium', low: '### 🟢 Low', }; +const SEVERITY_SUMMARY_LABELS = { + high: '🔴 High', + medium: '🟡 Medium', + low: '🟢 Low', +}; const STATUS_LABELS = { pass: '✅', @@ -41,6 +46,7 @@ const CODE_BLOCK_LANGUAGE_PATTERN = /^[A-Za-z0-9#+.-]{1,20}$/u; const MAX_FINDING_SNIPPETS = 2; const MAX_SNIPPET_LINES = 20; const MAX_SNIPPET_CHARACTERS = 1200; +const TOP_FINDINGS_LIMIT = 3; function cleanText(value) { return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''; @@ -224,35 +230,40 @@ function formatReviewContext(rendering) { const parts = []; if (rendering.mode) { - parts.push(`mode ${renderCode(rendering.mode)}`); - parts.push(REVIEW_MODE_DETAILS[rendering.mode]); + parts.push(renderCode(rendering.mode)); } - const scopeSummary = formatScopeSummary(rendering); - if (scopeSummary) { - parts.push(`scope ${scopeSummary}`); + if ( + typeof rendering.selectedFiles === 'number' && + typeof rendering.reviewableFiles === 'number' + ) { + parts.push(`${rendering.selectedFiles}/${rendering.reviewableFiles} files`); } - const packetCoverage = formatPacketCoverage(rendering); - if (packetCoverage) { - parts.push(`packet ${packetCoverage}`); + if ( + typeof rendering.selectedChanges === 'number' && + typeof rendering.reviewableChanges === 'number' + ) { + parts.push(`${rendering.selectedChanges}/${rendering.reviewableChanges} lines`); } - const turnBudget = formatTurnBudget(rendering); - if (turnBudget) { - parts.push(`turn budget ${turnBudget}`); + if ( + typeof rendering.packetIncludedFiles === 'number' && + typeof rendering.packetTotalFiles === 'number' + ) { + parts.push(`packet ${rendering.packetIncludedFiles}/${rendering.packetTotalFiles}`); } - const timeBudget = formatTimeBudget(rendering); - if (timeBudget) { - parts.push(`workflow cap ${timeBudget}`); + const runtimeBudget = formatCombinedBudget(rendering) || formatTimeBudget(rendering) || formatTurnBudget(rendering); + if (runtimeBudget) { + parts.push(runtimeBudget); } if (parts.length === 0) { return null; } - return `> 🧭 Review context: ${parts.join('; ')}.`; + return `> 🧭 ${parts.join(' • ')}`; } function classifyFallbackReason(reason) { @@ -612,8 +623,8 @@ export function normalizeStructuredOutput(raw) { return { ok: true, value }; } -function renderChecklistTable(title, labelHeader, labelKey, rows) { - const lines = ['', title, '', `| ${labelHeader} | Status | Notes |`, '|---|---|---|']; +function renderChecklistTable(labelHeader, labelKey, rows) { + const lines = [`| ${labelHeader} | Status | Notes |`, '|---|---|---|']; for (const row of rows) { lines.push( `| ${renderInlineText(row[labelKey])} | ${STATUS_LABELS[row.status]} | ${renderInlineText(row.notes)} |` @@ -622,9 +633,9 @@ function renderChecklistTable(title, labelHeader, labelKey, rows) { return lines; } -function renderBulletSection(title, items) { +function renderBulletSection(items) { if (items.length === 0) return []; - return ['', title, ...items.map((item) => `- ${renderInlineText(item)}`)]; + return items.map((item) => `- ${renderInlineText(item)}`); } function renderFindingSnippets(snippets) { @@ -646,46 +657,104 @@ function renderFindingSnippets(snippets) { return lines; } +function renderDetailsBlock(summary, bodyLines) { + if (!bodyLines.length) { + return []; + } + + return ['', '
', `${escapeMarkdown(summary)}`, '', ...bodyLines, '', '
']; +} + +function renderFindingReference(finding) { + return finding.line ? `${finding.file}:${finding.line}` : finding.file; +} + +function getOrderedFindings(findings) { + return SEVERITY_ORDER.flatMap((severity) => findings.filter((finding) => finding.severity === severity)); +} + +function renderTopFindings(findings) { + if (findings.length === 0) { + return ['No confirmed issues found after reviewing the diff and surrounding code.']; + } + + const orderedFindings = getOrderedFindings(findings); + const lines = orderedFindings + .slice(0, TOP_FINDINGS_LIMIT) + .map( + (finding) => + `- ${SEVERITY_SUMMARY_LABELS[finding.severity]} ${renderCode(renderFindingReference(finding))} — ${renderInlineText(finding.title)}` + ); + + if (orderedFindings.length > TOP_FINDINGS_LIMIT) { + const remaining = orderedFindings.length - TOP_FINDINGS_LIMIT; + lines.push(`- ${remaining} more finding${remaining === 1 ? '' : 's'} in the details below.`); + } + + return lines; +} + +function renderDetailedFindings(findings) { + if (findings.length === 0) { + return []; + } + + const lines = []; + for (const severity of SEVERITY_ORDER) { + const scopedFindings = findings.filter((finding) => finding.severity === severity); + if (scopedFindings.length === 0) continue; + + lines.push(`**${SEVERITY_SUMMARY_LABELS[severity]} (${scopedFindings.length})**`, ''); + for (const finding of scopedFindings) { + lines.push(`- **${renderCode(renderFindingReference(finding))} — ${renderInlineText(finding.title)}**`); + lines.push(` Problem: ${renderInlineText(finding.what)}`); + lines.push(` Why it matters: ${renderInlineText(finding.why)}`); + lines.push(` Suggested fix: ${renderInlineText(finding.fix)}`); + lines.push(...renderFindingSnippets(finding.snippets)); + lines.push(''); + } + } + + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines; +} + export function renderStructuredReview(review, { model, rendering: renderOptions } = {}) { const rendering = mergeRenderingMetadata(review?.rendering, renderOptions); - const lines = ['### 📋 Summary', '', renderInlineText(review.summary), '', '### 🔍 Findings']; + const lines = [ + '### Verdict', + '', + `**${ASSESSMENTS[review.overallAssessment]}** — ${renderInlineText(review.overallRationale)}`, + '', + renderInlineText(review.summary), + ]; const reviewContext = formatReviewContext(rendering); if (reviewContext) { - lines.splice(4, 0, reviewContext, ''); + lines.push('', reviewContext); } - if (review.findings.length === 0) { - lines.push('No confirmed issues found after reviewing the diff and surrounding code.'); - } else { - for (const severity of SEVERITY_ORDER) { - const findings = review.findings.filter((finding) => finding.severity === severity); - if (findings.length === 0) continue; - - lines.push('', SEVERITY_HEADERS[severity], ''); - for (const finding of findings) { - const location = finding.line ? `${finding.file}:${finding.line}` : finding.file; - lines.push(`- **${renderCode(location)} — ${renderInlineText(finding.title)}**`); - lines.push(` Problem: ${renderInlineText(finding.what)}`); - lines.push(` Why it matters: ${renderInlineText(finding.why)}`); - lines.push(` Suggested fix: ${renderInlineText(finding.fix)}`); - lines.push(...renderFindingSnippets(finding.snippets)); - lines.push(''); - } - } - if (lines[lines.length - 1] === '') lines.pop(); - } - - lines.push(...renderChecklistTable('### 🔒 Security Checklist', 'Check', 'check', review.securityChecklist)); - lines.push(...renderChecklistTable('### 📊 CCS Compliance', 'Rule', 'rule', review.ccsCompliance)); - lines.push(...renderBulletSection('### 💡 Informational', review.informational)); - lines.push(...renderBulletSection("### ✅ What's Done Well", review.strengths)); + lines.push('', '### Top Findings', '', ...renderTopFindings(review.findings)); + lines.push(...renderDetailsBlock(`Full Findings (${review.findings.length})`, renderDetailedFindings(review.findings))); + lines.push( + ...renderDetailsBlock( + `Security Checklist (${review.securityChecklist.length})`, + renderChecklistTable('Check', 'check', review.securityChecklist) + ) + ); + lines.push( + ...renderDetailsBlock( + `CCS Compliance (${review.ccsCompliance.length})`, + renderChecklistTable('Rule', 'rule', review.ccsCompliance) + ) + ); + lines.push(...renderDetailsBlock(`Informational (${review.informational.length})`, renderBulletSection(review.informational))); + lines.push(...renderDetailsBlock(`What's Done Well (${review.strengths.length})`, renderBulletSection(review.strengths))); lines.push( - '', - '### 🎯 Overall Assessment', - '', - `**${ASSESSMENTS[review.overallAssessment]}** — ${renderInlineText(review.overallRationale)}`, '', `> 🤖 Reviewed by \`${model}\`` ); diff --git a/scripts/github/run-ai-review-direct.mjs b/scripts/github/run-ai-review-direct.mjs index a2f37959..c1604c8a 100644 --- a/scripts/github/run-ai-review-direct.mjs +++ b/scripts/github/run-ai-review-direct.mjs @@ -283,7 +283,7 @@ export async function writeDirectReviewFromEnv(env = process.env, fetchImpl = gl const responseJson = await postReviewRequest({ apiUrl: cleanText(env.ANTHROPIC_BASE_URL), apiKey: cleanText(env.ANTHROPIC_AUTH_TOKEN), - model: cleanText(env.REVIEW_MODEL || env.ANTHROPIC_MODEL || 'glm-5.1'), + model: cleanText(env.REVIEW_MODEL || env.ANTHROPIC_MODEL || 'glm-5-turbo'), system, prompt: attemptPrompt, timeoutMs: attemptWindow.timeoutMs, @@ -318,11 +318,11 @@ export async function writeDirectReviewFromEnv(env = process.env, fetchImpl = gl const markdown = finalValidation ? renderStructuredReview(finalValidation, { - model: cleanText(env.REVIEW_MODEL || 'glm-5.1'), + model: cleanText(env.REVIEW_MODEL || 'glm-5-turbo'), rendering, }) : renderIncompleteReview({ - model: cleanText(env.REVIEW_MODEL || 'glm-5.1'), + model: cleanText(env.REVIEW_MODEL || 'glm-5-turbo'), reason: lastReason, runUrl: cleanText(env.AI_REVIEW_RUN_URL || '#'), selectedFiles: coveredSelectedFiles, diff --git a/tests/unit/scripts/github/normalize-ai-review-output.test.ts b/tests/unit/scripts/github/normalize-ai-review-output.test.ts index afefb07e..c34d45e0 100644 --- a/tests/unit/scripts/github/normalize-ai-review-output.test.ts +++ b/tests/unit/scripts/github/normalize-ai-review-output.test.ts @@ -52,21 +52,22 @@ describe('normalize-ai-review-output', () => { ); expect(validation.ok).toBe(true); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); - expect(markdown).toContain('### 📋 Summary'); - expect(markdown).toContain('### 🔴 High'); + expect(markdown).toContain('### Verdict'); + expect(markdown).toContain('### Top Findings'); + expect(markdown).toContain('- 🔴 High `src/cliproxy/accounts/query.ts:61` — Ambiguous account lookup drops valid matches'); + expect(markdown).toContain('Full Findings (1)'); expect(markdown).toContain('**`src/cliproxy/accounts/query.ts:61` — Ambiguous account lookup drops valid matches**'); - expect(markdown).toContain('### 🔒 Security Checklist'); + expect(markdown).toContain('Security Checklist (1)'); expect(markdown).toContain('| Injection safety | ✅ | No user-controlled input reaches a shell, SQL, or HTML boundary in this diff. |'); - expect(markdown).toContain('### 📊 CCS Compliance'); + expect(markdown).toContain('CCS Compliance (1)'); expect(markdown).toContain('| No emojis in CLI | N/A | This change affects GitHub PR comments only, not CLI stdout. |'); - expect(markdown).toContain('### 💡 Informational'); - expect(markdown).toContain('### ✅ What\'s Done Well'); - expect(markdown).toContain('### 🎯 Overall Assessment'); + expect(markdown).toContain('Informational (1)'); + expect(markdown).toContain("What's Done Well (1)"); expect(markdown).toContain('**❌ CHANGES REQUESTED**'); expect(markdown).toContain('Why it matters: That breaks normal selection flows for users with multiple Codex sessions.'); - expect(markdown).toContain('> 🤖 Reviewed by `glm-5.1`'); + expect(markdown).toContain('> 🤖 Reviewed by `glm-5-turbo`'); }); test('renders mode-aware review context metadata without changing the structured review contract', () => { @@ -97,7 +98,7 @@ describe('normalize-ai-review-output', () => { expect(validation.ok).toBe(true); const markdown = reviewOutput.renderStructuredReview(validation.value, { - model: 'glm-5.1', + model: 'glm-5-turbo', rendering: { mode: 'triage', selectedFiles: 8, @@ -113,7 +114,7 @@ describe('normalize-ai-review-output', () => { }); expect(markdown).toContain( - '> 🧭 Review context: mode `triage`; expanded packaged review with broader coverage; scope 8/34 reviewable files; 620/2140 reviewable changed lines; packet 6/8 selected files included in the final review packet; 2 selected files omitted for packet budget; turn budget 6 turns; workflow cap 5 minutes.' + '> 🧭 `triage` • 8/34 files • 620/2140 lines • packet 6/8 • 6 turns / 5 minutes' ); expect(markdown).toContain('**⚠️ APPROVED WITH NOTES**'); }); @@ -157,7 +158,7 @@ describe('normalize-ai-review-output', () => { ); expect(validation.ok).toBe(true); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); expect(markdown).toContain('`buildReviewScope(files, mode)`'); expect(markdown).toContain('`.github/workflows/ai-review.yml`'); @@ -200,7 +201,7 @@ describe('normalize-ai-review-output', () => { ); expect(validation.ok).toBe(true); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); expect(markdown).toContain('Evidence: Current publish branch'); expect(markdown).toContain('```bash'); @@ -242,7 +243,7 @@ describe('normalize-ai-review-output', () => { expect(validation.ok).toBe(true); expect(validation.value.findings[0].snippets[0].code).toBe(' if value:\n print(value)'); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); expect(markdown).toContain(' if value:'); expect(markdown).toContain(' print(value)'); }); @@ -300,7 +301,7 @@ describe('normalize-ai-review-output', () => { const result = reviewOutput.writeReviewFromEnv({ AI_REVIEW_EXECUTION_FILE: executionFile, - AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_MODEL: 'glm-5-turbo', AI_REVIEW_MODE: 'triage', AI_REVIEW_SELECTED_FILES: '10', AI_REVIEW_REVIEWABLE_FILES: '46', @@ -363,7 +364,7 @@ describe('normalize-ai-review-output', () => { const result = reviewOutput.writeReviewFromEnv({ AI_REVIEW_EXECUTION_FILE: executionFile, - AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_MODEL: 'glm-5-turbo', AI_REVIEW_MODE: 'fast', AI_REVIEW_SELECTED_FILES: '6', AI_REVIEW_REVIEWABLE_FILES: '52', @@ -410,7 +411,7 @@ describe('normalize-ai-review-output', () => { const result = reviewOutput.writeReviewFromEnv({ AI_REVIEW_EXECUTION_FILE: executionFile, - AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_MODEL: 'glm-5-turbo', AI_REVIEW_OUTPUT_FILE: outputFile, AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1', AI_REVIEW_STRUCTURED_OUTPUT: JSON.stringify({ @@ -507,15 +508,15 @@ describe('normalize-ai-review-output', () => { ); expect(validation.ok).toBe(true); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); - expect(markdown).toContain('### 🔍 Findings'); + expect(markdown).toContain('### Top Findings'); expect(markdown).toContain('No confirmed issues found after reviewing the diff and surrounding code.'); - expect(markdown).toContain('### 🔒 Security Checklist'); + expect(markdown).toContain('Security Checklist (1)'); expect(markdown).toContain( '| Injection safety | ✅ | No user-controlled data crosses a risky boundary in the reviewed diff. |' ); - expect(markdown).toContain('### 📊 CCS Compliance'); + expect(markdown).toContain('CCS Compliance (1)'); expect(markdown).toContain('| Help/docs alignment | N/A | No CLI behavior changed, so there was nothing to update. |'); expect(markdown).toContain('**✅ APPROVED** — No confirmed regressions or missing verification remain.'); }); @@ -545,7 +546,7 @@ describe('normalize-ai-review-output', () => { ); expect(validation.ok).toBe(true); - const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5-turbo' }); expect(markdown).toContain( '**`tests/unit/scripts/github/normalize-ai-review-output.test.ts` — Missing empty-state coverage**' @@ -575,7 +576,7 @@ describe('normalize-ai-review-output', () => { overallAssessment: 'approved_with_notes', overallRationale: 'This is a formatting-only follow-up.', }, - { model: 'glm-5.1' } + { model: 'glm-5-turbo' } ); expect(markdown).toContain('**``src/weird`path.ts`` — Backtick-safe locations stay readable**'); diff --git a/tests/unit/scripts/github/run-ai-review-direct.test.ts b/tests/unit/scripts/github/run-ai-review-direct.test.ts index 51331f3c..8131f3dd 100644 --- a/tests/unit/scripts/github/run-ai-review-direct.test.ts +++ b/tests/unit/scripts/github/run-ai-review-direct.test.ts @@ -60,7 +60,7 @@ describe('run-ai-review-direct', () => { { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', - REVIEW_MODEL: 'glm-5.1', + REVIEW_MODEL: 'glm-5-turbo', GITHUB_REPOSITORY: 'kaitranntt/ccs', AI_REVIEW_PROMPT: 'You are a reviewer.', AI_REVIEW_PACKET_FILE: packetFile, @@ -110,7 +110,7 @@ describe('run-ai-review-direct', () => { { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', - REVIEW_MODEL: 'glm-5.1', + REVIEW_MODEL: 'glm-5-turbo', GITHUB_REPOSITORY: 'kaitranntt/ccs', AI_REVIEW_PROMPT: 'You are a reviewer.', AI_REVIEW_PACKET_FILE: packetFile, @@ -147,11 +147,9 @@ describe('run-ai-review-direct', () => { ); expect(result.usedFallback).toBe(false); - expect(fs.readFileSync(outputFile, 'utf8')).toContain('### 📋 Summary'); + expect(fs.readFileSync(outputFile, 'utf8')).toContain('### Verdict'); expect(fs.readFileSync(outputFile, 'utf8')).toContain('**✅ APPROVED**'); - expect(fs.readFileSync(outputFile, 'utf8')).toContain( - 'packet 1/1 selected files included in the final review packet' - ); + expect(fs.readFileSync(outputFile, 'utf8')).toContain('packet 1/1'); expect(JSON.parse(fs.readFileSync(logFile, 'utf8')).attempts).toHaveLength(1); }); }); @@ -171,7 +169,7 @@ describe('run-ai-review-direct', () => { { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', - REVIEW_MODEL: 'glm-5.1', + REVIEW_MODEL: 'glm-5-turbo', GITHUB_REPOSITORY: 'kaitranntt/ccs', AI_REVIEW_PROMPT: 'You are a reviewer.', AI_REVIEW_PACKET_FILE: packetFile, @@ -263,7 +261,7 @@ describe('run-ai-review-direct', () => { { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', - REVIEW_MODEL: 'glm-5.1', + REVIEW_MODEL: 'glm-5-turbo', GITHUB_REPOSITORY: 'kaitranntt/ccs', AI_REVIEW_PROMPT: 'You are a reviewer.', AI_REVIEW_PACKET_FILE: packetFile, @@ -289,9 +287,7 @@ describe('run-ai-review-direct', () => { expect(result.usedFallback).toBe(false); expect(fs.readFileSync(outputFile, 'utf8')).toContain('**⚠️ APPROVED WITH NOTES**'); - expect(fs.readFileSync(outputFile, 'utf8')).toContain( - 'packet 2/3 selected files included in the final review packet; 1 selected file omitted for packet budget' - ); + expect(fs.readFileSync(outputFile, 'utf8')).toContain('packet 2/3'); expect(JSON.parse(fs.readFileSync(logFile, 'utf8')).attempts).toHaveLength(2); }); });