diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index b4f9f47b55..29b9d30f8b 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -25,12 +25,11 @@ jobs: echo "## Changed-code mutation testing" >> "$GITHUB_STEP_SUMMARY" echo "Mutation testing was enforced on each pull request before it entered the merge queue." >> "$GITHUB_STEP_SUMMARY" - - name: Checkout pull request head + - name: Checkout pull request merge result if: github.event_name == 'pull_request' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -55,10 +54,11 @@ jobs: if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_SHA: ${{ github.sha }} run: node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" - name: Upload mutation reports + id: mutation_report if: always() && github.event_name == 'pull_request' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -66,3 +66,14 @@ jobs: path: reports/mutation/ if-no-files-found: ignore retention-days: 7 + + - name: Link mutation report artifact + if: always() && github.event_name == 'pull_request' && steps.mutation_report.outputs.artifact-url != '' + env: + ARTIFACT_URL: ${{ steps.mutation_report.outputs.artifact-url }} + run: | + { + echo "" + echo "### Download mutation reports" + echo "[Open the changed-code-mutation-report artifact]($ARTIFACT_URL), then open the package's mutation.html file." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index ede7d9defe..c0e8a6cd1a 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -304,14 +304,23 @@ export function preferDirectTestFiles(testFiles, sourceFiles) { return direct.length > 0 ? direct : testFiles } -function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { +export function resolveVitestBinary(repoRoot, packageEntry) { + const packageRoot = path.join(repoRoot, packageEntry.root) + const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) + const candidates = [...new Set([runRoot, packageRoot, repoRoot])].map((root) => + path.join(root, "node_modules/.bin/vitest"), + ) + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates.at(-1) +} + +export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { const packageRoot = path.join(repoRoot, packageEntry.root) const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) const outputFile = path.join(reportDirectory, "vitest-related.json") const configFile = path.relative(runRoot, path.join(packageRoot, packageEntry.vitestConfig)).replaceAll("\\", "/") const sourceFiles = [...new Set(packageEntry.selectors.map(selectorFile))] const result = spawnSync( - path.join(repoRoot, "node_modules/.bin/vitest"), + resolveVitestBinary(repoRoot, packageEntry), ["related", ...sourceFiles, "--run", "--config", configFile, "--reporter=json", `--outputFile=${outputFile}`], { cwd: runRoot, @@ -325,6 +334,9 @@ function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { if (result.error?.code === "ETIMEDOUT") { throw new Error(`${packageEntry.id} related-test discovery exceeded 5 minutes`) } + if (result.error) { + throw new Error(`${packageEntry.id} related-test discovery could not start: ${result.error.message}`) + } if (result.status !== 0) { throw new Error( `${packageEntry.id} related-test discovery failed:\n${stripAnsi(`${result.stdout ?? ""}${result.stderr ?? ""}`).trim()}`, @@ -378,6 +390,11 @@ function runStryker(repoRoot, packageEntry, reportRoot, dryRunOnly) { `${packageEntry.id} mutation run exceeded 12 minutes. Split the PR or obtain a maintainer-reviewed narrow exclusion.`, ) } + if (result.error) { + throw new Error( + `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} could not start: ${result.error.message}`, + ) + } if (result.status !== 0) { throw new Error( `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} failed:\n${stripAnsi(output).trim()}`, @@ -405,17 +422,19 @@ export function mutantCounts(report) { return counts } -function escapeWorkflowValue(value) { - return String(value) - .replaceAll("%", "%25") - .replaceAll("\r", "%0D") - .replaceAll("\n", "%0A") - .replaceAll(":", "%3A") - .replaceAll(",", "%2C") +function escapeWorkflowData(value) { + return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A") +} + +function escapeWorkflowProperty(value) { + return escapeWorkflowData(value).replaceAll(":", "%3A").replaceAll(",", "%2C") } -export function formatAnnotations(blockingMutants, packageRoot) { - const perFile = new Map() +export function formatAnnotationCommand(annotation) { + return `::error file=${escapeWorkflowProperty(annotation.file)},line=${annotation.line},title=Mutation test gap::${escapeWorkflowData(annotation.message)}` +} + +export function formatAnnotations(blockingMutants, packageRoot, state = { total: 0, perFile: new Map() }) { const annotations = [] for (const mutant of blockingMutants.sort((left, right) => { @@ -424,9 +443,8 @@ export function formatAnnotations(blockingMutants, packageRoot) { })) { const repositoryPath = path.posix.join(packageRoot, mutant.filePath.replaceAll("\\", "/")) const key = `${repositoryPath}:${mutant.location.start.line}` - const fileCount = perFile.get(repositoryPath) ?? 0 - if (annotations.some((annotation) => annotation.key === key) || fileCount >= 7 || annotations.length >= 20) - continue + const fileCount = state.perFile.get(repositoryPath) ?? 0 + if (annotations.some((annotation) => annotation.key === key) || fileCount >= 7 || state.total >= 20) continue const replacement = String(mutant.replacement ?? "") .replace(/\s+/g, " ") @@ -438,17 +456,59 @@ export function formatAnnotations(blockingMutants, packageRoot) { line: mutant.location.start.line, message: `${mutant.status} ${mutant.mutatorName} mutant${replacement ? ` (replacement: ${replacement})` : ""}. ` + - "Add or strengthen a focused test that fails under this mutation, or add a maintainer-approved targeted exclusion with a reason.", + "See the job summary for the complete list and resolution guidance.", }) - perFile.set(repositoryPath, fileCount + 1) + state.perFile.set(repositoryPath, fileCount + 1) + state.total++ } return annotations } -function appendSummary(rows, failures) { - if (!process.env.GITHUB_STEP_SUMMARY) return +function markdownCell(value) { + return String(value ?? "—") + .replace(/\s+/g, " ") + .trim() + .replaceAll("|", "\\|") + .slice(0, 120) +} + +export function testsFromMutationReport(report, fallback = []) { + const testFiles = Object.keys(report.testFiles ?? {}) + return testFiles.length > 0 ? testFiles : fallback +} + +export function formatBlockingMutants(blockingMutants, packageRoot) { + const grouped = new Map() + for (const mutant of [...blockingMutants].sort((left, right) => { + const pathOrder = left.filePath.localeCompare(right.filePath) + return pathOrder || left.location.start.line - right.location.start.line + })) { + const repositoryPath = path.posix.join(packageRoot, mutant.filePath.replaceAll("\\", "/")) + const group = grouped.get(repositoryPath) ?? [] + group.push(mutant) + grouped.set(repositoryPath, group) + } + + const lines = [] + for (const [filePath, mutants] of grouped) { + lines.push( + `#### \`${filePath}\``, + "", + "| Line | Status | Mutator | Replacement |", + "| ---: | --- | --- | --- |", + ) + for (const mutant of mutants) { + lines.push( + `| ${mutant.location.start.line} | ${markdownCell(mutant.status)} | ${markdownCell(mutant.mutatorName)} | ${markdownCell(mutant.replacement)} |`, + ) + } + lines.push("") + } + return lines +} +export function formatSummary(rows, failures, manifest = {}) { const lines = [ "## Changed-code mutation testing", "", @@ -461,8 +521,89 @@ function appendSummary(rows, failures) { ) } if (rows.length === 0) lines.push("| — | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |") - if (failures.length > 0) lines.push("", ...failures.map((failure) => `- ${failure}`)) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`) + + if (rows.length > 0) { + lines.push("", "### Focused tests") + for (const row of rows) { + const cwd = row.runRoot ?? row.root + if (row.testFiles?.length > 0) { + lines.push(`- **${row.id}** (cwd \`${cwd}\`): ${row.testFiles.map((file) => `\`${file}\``).join(", ")}`) + } else { + lines.push( + `- **${row.id}** (cwd \`${cwd}\`): the mutation run did not complete far enough to report its selected tests; use the exact reproduction command below.`, + ) + } + } + } + + const blockingRows = rows.filter((row) => row.blocking?.length > 0) + if (blockingRows.length > 0) { + lines.push( + "", + "### All surviving and uncovered mutants", + "", + "Annotations highlight up to 20 unique locations (maximum 7 per file). This summary lists every blocking mutant.", + "", + ) + for (const row of blockingRows) { + lines.push(`### ${row.id}`, "", ...formatBlockingMutants(row.blocking, row.runRoot ?? row.root)) + } + lines.push( + "### Resolve a mutation gap", + "", + "Add or strengthen a focused test that fails under the mutation. If the mutant is equivalent, request maintainer approval for the narrowest mutator-specific exclusion and explain why it cannot change behavior:", + "", + "```ts", + "// Stryker disable next-line ConditionalExpression: normalized input cannot reach the alternate branch", + "const result = condition ? value : fallback", + "```", + "", + "Broad `all` exclusions and exclusions without a concrete reason are rejected by the gate.", + ) + } + + if (manifest.baseSha && manifest.headSha) { + lines.push( + "", + "### Reproduce locally", + "", + "From a full checkout containing both commits:", + "", + "```bash", + "pnpm install --frozen-lockfile", + `node scripts/stryker-diff.mjs ci --base ${manifest.baseSha} --head ${manifest.headSha}`, + "```", + ) + } + + if (rows.length > 0) { + lines.push("", "### Mutation reports", "") + for (const row of rows) lines.push(`- **${row.id}:** \`${row.reportPath}\``) + lines.push( + "", + "The workflow uploads generated reports in the `changed-code-mutation-report` artifact. A direct artifact link appears below after upload.", + ) + } + + if (failures.length > 0) { + lines.push( + "", + "### Failures", + "", + ...failures.map((failure) => { + const detail = + failure.length > 4_000 ? `${failure.slice(0, 4_000)}\n[truncated; see the step log]` : failure + return `- ${detail.replaceAll("\n", "\n ")}` + }), + ) + } + + return `${lines.join("\n")}\n` +} + +function appendSummary(rows, failures, manifest) { + if (!process.env.GITHUB_STEP_SUMMARY) return + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatSummary(rows, failures, manifest)) } export function evaluateReport(report, packageEntry) { @@ -491,9 +632,13 @@ export function evaluateReport(report, packageEntry) { export function runManifest(repoRoot, manifest, reportRoot) { const rows = [] const failures = [] + const annotationState = { total: 0, perFile: new Map() } for (const packageEntry of manifest.packages) { let counts + const reportPath = path + .relative(repoRoot, path.join(reportRoot, packageEntry.id, "mutation.html")) + .replaceAll("\\", "/") try { const reportDirectory = path.join(reportRoot, packageEntry.id) fs.mkdirSync(reportDirectory, { recursive: true }) @@ -514,6 +659,11 @@ export function runManifest(repoRoot, manifest, reportRoot) { if (generatedMutants === 0) { rows.push({ id: packageEntry.id, + root: packageEntry.root, + runRoot: packageEntry.runRoot, + selectors: packageEntry.selectors, + testFiles: packageEntry.testFiles ?? [], + reportPath, changedLines: packageEntry.changedExecutableLines, valid: 0, killed: 0, @@ -526,17 +676,25 @@ export function runManifest(repoRoot, manifest, reportRoot) { } runStryker(repoRoot, packageEntry, reportRoot, false) - const reportPath = path.join(reportRoot, packageEntry.id, "mutation.json") - const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) + const jsonReportPath = path.join(reportRoot, packageEntry.id, "mutation.json") + const report = JSON.parse(fs.readFileSync(jsonReportPath, "utf8")) + packageEntry.testFiles = testsFromMutationReport(report, packageEntry.testFiles) counts = mutantCounts(report) - for (const annotation of formatAnnotations(counts.blocking, packageEntry.runRoot ?? packageEntry.root)) { - console.log( - `::error file=${escapeWorkflowValue(annotation.file)},line=${annotation.line},title=Mutation test gap::${escapeWorkflowValue(annotation.message)}`, - ) + for (const annotation of formatAnnotations( + counts.blocking, + packageEntry.runRoot ?? packageEntry.root, + annotationState, + )) { + console.log(formatAnnotationCommand(annotation)) } evaluateReport(report, packageEntry) rows.push({ id: packageEntry.id, + root: packageEntry.root, + runRoot: packageEntry.runRoot, + selectors: packageEntry.selectors, + testFiles: packageEntry.testFiles ?? [], + reportPath, changedLines: packageEntry.changedExecutableLines, ...counts, result: "Passed", @@ -545,18 +703,24 @@ export function runManifest(repoRoot, manifest, reportRoot) { failures.push(error.message) rows.push({ id: packageEntry.id, + root: packageEntry.root, + runRoot: packageEntry.runRoot, + selectors: packageEntry.selectors, + testFiles: packageEntry.testFiles ?? [], + reportPath, changedLines: packageEntry.changedExecutableLines, valid: counts?.valid ?? 0, killed: counts?.killed ?? 0, timeout: counts?.timeout ?? 0, survived: counts?.survived ?? 0, noCoverage: counts?.noCoverage ?? 0, + blocking: counts?.blocking ?? [], result: "Failed", }) } } - appendSummary(rows, failures) + appendSummary(rows, failures, manifest) if (failures.length > 0) throw new Error(failures.join("\n")) return rows } @@ -579,7 +743,7 @@ function main() { const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation") const manifest = selectFromGit(repoRoot, baseSha, headSha) if (manifest.packages.length === 0) { - appendSummary([], []) + appendSummary([], [], manifest) console.log("No changed executable lines in mutation-tested packages; mutation testing is not applicable.") return } diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index af1687ec68..0f39dc507f 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -4,24 +4,56 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import { describe, it } from "node:test" +import { fileURLToPath } from "node:url" import { MAX_CHANGED_LINES, MAX_MUTANTS, + PACKAGE_CONFIGS, buildManifest, + discoverRelatedTestFiles, evaluateReport, executableChangedLines, formatAnnotations, + formatAnnotationCommand, + formatBlockingMutants, + formatSummary, mutantCounts, parseChangedLines, parseNameStatus, parseVitestTestFiles, preferDirectTestFiles, + resolveVitestBinary, packageForPath, + runManifest, selectFromGit, + testsFromMutationReport, validateDisableDirectives, } from "./stryker-diff.mjs" +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + +describe("mutation testing workflow", () => { + it("checks out the pull request merge result from the base repository", () => { + const workflow = fs.readFileSync(path.join(repositoryRoot, ".github/workflows/mutation-testing.yml"), "utf8") + + assert.ok(workflow.includes(" pull_request:")) + assert.ok(!workflow.includes("pull_request_target:")) + assert.ok(workflow.includes(" contents: read")) + assert.ok(workflow.includes("- name: Checkout pull request merge result")) + assert.ok(workflow.includes("ref: ${{ github.sha }}")) + assert.ok(!workflow.includes("ref: refs/pull/${{ github.event.pull_request.number }}/merge")) + assert.ok(workflow.includes("fetch-depth: 0")) + assert.ok(workflow.includes("persist-credentials: false")) + assert.ok(!workflow.includes("repository: ${{ github.event.pull_request.head.repo.full_name }}")) + assert.ok(!workflow.includes("ref: ${{ github.event.pull_request.head.sha }}")) + assert.ok(workflow.includes("HEAD_SHA: ${{ github.sha }}")) + assert.ok(!workflow.includes("HEAD_SHA: ${{ github.event.pull_request.head.sha }}")) + assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) + assert.ok(workflow.includes("open the package's mutation.html file")) + }) +}) + describe("parseNameStatus", () => { it("parses added, modified, and renamed paths", () => { assert.deepEqual( @@ -178,6 +210,55 @@ describe("preferDirectTestFiles", () => { }) }) +describe("related-test discovery", () => { + it("resolves Vitest from each package before falling back to the repository", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-vitest-")) + const extension = PACKAGE_CONFIGS.find(({ id }) => id === "extension") + const webview = PACKAGE_CONFIGS.find(({ id }) => id === "webview") + const extensionBinary = path.join(repo, "src/node_modules/.bin/vitest") + const webviewBinary = path.join(repo, "webview-ui/node_modules/.bin/vitest") + const rootBinary = path.join(repo, "node_modules/.bin/vitest") + + try { + fs.mkdirSync(path.dirname(extensionBinary), { recursive: true }) + fs.mkdirSync(path.dirname(webviewBinary), { recursive: true }) + fs.writeFileSync(extensionBinary, "") + fs.writeFileSync(webviewBinary, "") + + assert.equal(resolveVitestBinary(repo, extension), extensionBinary) + assert.equal(resolveVitestBinary(repo, webview), webviewBinary) + + fs.rmSync(extensionBinary) + fs.mkdirSync(path.dirname(rootBinary), { recursive: true }) + fs.writeFileSync(rootBinary, "") + assert.equal(resolveVitestBinary(repo, extension), rootBinary) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) + + it("reports a Vitest launch error when no binary exists", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-vitest-")) + const reportDirectory = path.join(repo, "reports") + const packageEntry = { + id: "extension", + root: "src", + vitestConfig: "vitest.config.ts", + selectors: ["utils/value.ts:1-1"], + } + + try { + fs.mkdirSync(path.join(repo, "src"), { recursive: true }) + assert.throws( + () => discoverRelatedTestFiles(repo, packageEntry, reportDirectory), + /extension related-test discovery could not start:.*ENOENT/, + ) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) +}) + describe("selectFromGit", () => { it("derives changed executable ranges from the base/head merge base", () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-diff-")) @@ -214,6 +295,43 @@ describe("selectFromGit", () => { fs.rmSync(repo, { recursive: true, force: true }) } }) + + it("uses merge-result line coordinates when the base shifts a pull request edit", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-merge-diff-")) + const runGit = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim() + + try { + runGit("init", "--initial-branch=main") + runGit("config", "user.name", "Mutation Test") + runGit("config", "user.email", "mutation@example.com") + fs.mkdirSync(path.join(repo, "packages/core/src"), { recursive: true }) + fs.writeFileSync(path.join(repo, "packages/core/src/value.ts"), "const first = 1\nconst changed = true\n") + runGit("add", ".") + runGit("commit", "-m", "initial") + + runGit("checkout", "-b", "feature") + fs.writeFileSync(path.join(repo, "packages/core/src/value.ts"), "const first = 1\nconst changed = false\n") + runGit("commit", "-am", "change value") + + runGit("checkout", "main") + fs.writeFileSync( + path.join(repo, "packages/core/src/value.ts"), + "const inserted = 0\nconst first = 1\nconst changed = true\n", + ) + runGit("commit", "-am", "shift source lines") + const baseSha = runGit("rev-parse", "HEAD") + runGit("merge", "--no-ff", "feature", "-m", "merge feature") + const mergeSha = runGit("rev-parse", "HEAD") + + const manifest = selectFromGit(repo, baseSha, mergeSha) + assert.deepEqual( + manifest.packages.map(({ id, selectors }) => ({ id, selectors })), + [{ id: "core", selectors: ["src/value.ts:3-3"] }], + ) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) }) describe("mutation exclusions", () => { @@ -249,6 +367,198 @@ describe("mutation exclusions", () => { }) }) +describe("failure output", () => { + const blocking = [ + { + filePath: "core/value.ts", + status: "Survived", + mutatorName: "ConditionalExpression", + replacement: "true", + location: { start: { line: 4 } }, + }, + { + filePath: "core/value.ts", + status: "NoCoverage", + mutatorName: "StringLiteral", + replacement: '"left | right"', + location: { start: { line: 4 } }, + }, + { + filePath: "utils/other.ts", + status: "Survived", + mutatorName: "BooleanLiteral", + replacement: "false", + location: { start: { line: 9 } }, + }, + ] + + it("lists every blocking mutant with tests, reproduction, exclusion, and report guidance", () => { + const baseSha = "a".repeat(40) + const headSha = "b".repeat(40) + const summary = formatSummary( + [ + { + id: "extension", + root: "src", + selectors: ["core/value.ts:4-4"], + testFiles: ["core/__tests__/value.test.ts"], + reportPath: "reports/mutation/extension/mutation.html", + changedLines: 1, + valid: 3, + killed: 0, + timeout: 0, + survived: 2, + noCoverage: 1, + blocking, + result: "Failed", + }, + ], + ["extension has blocking mutants"], + { baseSha, headSha }, + ) + + assert.ok(summary.includes("`core/__tests__/value.test.ts`")) + assert.ok(summary.includes("#### `src/core/value.ts`")) + assert.ok(summary.includes("#### `src/utils/other.ts`")) + for (const mutant of blocking) assert.ok(summary.includes(mutant.mutatorName)) + assert.ok(summary.includes('"left \\| right"')) + assert.ok(summary.includes(`node scripts/stryker-diff.mjs ci --base ${baseSha} --head ${headSha}`)) + assert.ok(summary.includes("Stryker disable next-line ConditionalExpression:")) + assert.ok(summary.includes("`reports/mutation/extension/mutation.html`")) + assert.ok(summary.includes("`changed-code-mutation-report` artifact")) + }) + + it("caps annotations without truncating the grouped summary", () => { + const manyMutants = Array.from({ length: 30 }, (_, index) => ({ + filePath: `file-${Math.floor(index / 10)}.ts`, + status: "Survived", + mutatorName: `Mutator${index}`, + replacement: `replacement-${index}`, + location: { start: { line: (index % 10) + 1 } }, + })) + const annotations = formatAnnotations(manyMutants, "src") + const grouped = formatBlockingMutants(manyMutants, "src").join("\n") + + assert.equal(annotations.length, 20) + for (const file of new Set(annotations.map(({ file }) => file))) { + assert.ok(annotations.filter((annotation) => annotation.file === file).length <= 7) + } + for (const mutant of manyMutants) assert.ok(grouped.includes(mutant.mutatorName)) + }) + + it("shares annotation limits across packages", () => { + const state = { total: 0, perFile: new Map() } + const first = formatAnnotations( + Array.from({ length: 15 }, (_, index) => ({ + filePath: `first-${index}.ts`, + status: "Survived", + mutatorName: "BooleanLiteral", + location: { start: { line: 1 } }, + })), + "packages/core", + state, + ) + const second = formatAnnotations( + Array.from({ length: 15 }, (_, index) => ({ + filePath: `second-${index}.ts`, + status: "NoCoverage", + mutatorName: "StringLiteral", + location: { start: { line: 1 } }, + })), + "packages/cloud", + state, + ) + + assert.equal(first.length, 15) + assert.equal(second.length, 5) + assert.equal(state.total, 20) + }) + + it("preserves punctuation in annotation messages while escaping properties", () => { + const command = formatAnnotationCommand({ + file: "src/value:one,two.ts", + line: 4, + message: "Survived mutant (replacement: left, right). 100% reproducible.", + }) + + assert.equal( + command, + "::error file=src/value%3Aone%2Ctwo.ts,line=4,title=Mutation test gap::Survived mutant (replacement: left, right). 100%25 reproducible.", + ) + }) + + it("reports a Stryker preflight launch error when the binary is missing", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-launch-")) + const reportRoot = path.join(repo, "reports") + + try { + fs.mkdirSync(path.join(repo, "packages/core"), { recursive: true }) + assert.throws( + () => + runManifest( + repo, + { + packages: [ + { + id: "core", + root: "packages/core", + vitestConfig: "vitest.unit.config.ts", + selectors: ["src/value.ts:1-1"], + changedExecutableLines: 1, + }, + ], + }, + reportRoot, + ), + /core Stryker preflight could not start:.*ENOENT/, + ) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) + + it("uses the actual tests recorded by Stryker", () => { + assert.deepEqual( + testsFromMutationReport({ testFiles: { "src/value.test.ts": {}, "src/other.spec.ts": {} } }, [ + "fallback.test.ts", + ]), + ["src/value.test.ts", "src/other.spec.ts"], + ) + assert.deepEqual(testsFromMutationReport({}, ["fallback.test.ts"]), ["fallback.test.ts"]) + }) + + it("keeps the maximum blocking-mutant inventory within GitHub's summary limit", () => { + const rows = Array.from({ length: 6 }, (_, packageIndex) => ({ + id: `package-${packageIndex}`, + root: `packages/package-${packageIndex}`, + selectors: ["src/value.ts:1-500"], + testFiles: ["src/value.test.ts"], + reportPath: `reports/mutation/package-${packageIndex}/mutation.html`, + changedLines: 500, + valid: MAX_MUTANTS, + killed: 0, + timeout: 0, + survived: MAX_MUTANTS, + noCoverage: 0, + blocking: Array.from({ length: MAX_MUTANTS }, (_, mutantIndex) => ({ + filePath: `src/file-${mutantIndex}.ts`, + status: "Survived", + mutatorName: `Package${packageIndex}Mutator${mutantIndex}`, + replacement: "x".repeat(1_000), + location: { start: { line: 1 } }, + })), + result: "Failed", + })) + const summary = formatSummary(rows, ["mutation failure"], { + baseSha: "a".repeat(40), + headSha: "b".repeat(40), + }) + + assert.equal(new Set(summary.match(/Package\dMutator\d+/g)).size, 6 * MAX_MUTANTS) + assert.ok(Buffer.byteLength(summary) < 1024 * 1024) + }) +}) + describe("report evaluation", () => { const packageEntry = { id: "core", root: "packages/core" }