Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 73 additions & 13 deletions __tests__/run-batched-mutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ const scriptPath = join(
const runner = (await import('../scripts/run-batched-mutation-lib.mjs')) as {
defaultPnpmCommand: (platform?: NodeJS.Platform) => string
isEntrypoint: (metaUrl: string, argv1: string | undefined) => boolean
PER_FILE_CONFIG: string
runBatchedMutation: (options?: {
command?: string
cwd?: string
runCommand?: (command: string, args: string[], options: { cwd: string; stdio: string }) => void
stderr?: (message: string) => void
stdout?: (message: string) => void
}) => number
strykerArgs: (file: string) => string[]
strykerArgs: () => string[]
}

const tempDirs: string[] = []
Expand All @@ -45,14 +46,20 @@ describe('batched mutation runner', () => {
expect(runner.isEntrypoint(pathToFileURL(scriptPath).href, undefined)).toBe(false)
})

it('disables per-file Stryker break exits before enforcing the aggregate gate', async () => {
it('invokes Stryker per file without the unsupported --thresholds.break flag', async () => {
const result = await runFixture({ statuses: ['Killed'] })

expect(result.exitCode).toBe(0)
expect(result.command).toBe('pnpm')
expect(result.args).toEqual(runner.strykerArgs('src/example.ts'))
expect(result.args).toContain('--thresholds.break')
expect(result.args).toContain('0')
expect(result.args).toEqual(runner.strykerArgs())
// Stryker 9.x removed dot-notation CLI options, so passing --thresholds.break
// makes every per-file run fail with "unknown option". The generated
// per-file config disables the break threshold for the subprocess instead.
expect(result.args).not.toContain('--thresholds.break')
await expect(readPerFileConfig(result.cwd)).resolves.toMatchObject({
mutate: ['src/example.ts'],
thresholds: { break: null },
})
})

it('fails all-error reports instead of treating an empty valid denominator as 100%', async () => {
Expand Down Expand Up @@ -101,6 +108,32 @@ describe('batched mutation runner', () => {
})
})

it('passes when a per-file threshold exit still meets the aggregate gate', async () => {
const result = await runFixture({
childStatuses: { 'src/example.ts': 1 },
files: ['src/example.ts', 'src/covered.ts'],
statusesByFile: {
'src/covered.ts': ['Killed', 'Killed'],
'src/example.ts': ['Survived'],
},
threshold: 65,
})

expect(result.exitCode).toBe(0)
expect(result.stderr).not.toContain('subprocesses failed')

const aggregate = (await readAggregate(result.cwd)) as {
score: number
threshold: number
totals: { killed: number; survived: number }
}
expect(aggregate.score).toBeCloseTo(66.67, 2)
expect(aggregate).toMatchObject({
threshold: 65,
totals: { killed: 2, survived: 1 },
})
})

it('fails when Stryker does not produce a report', async () => {
const result = await runFixture({ writeReport: false })

Expand All @@ -120,6 +153,18 @@ describe('batched mutation runner', () => {
})
})

it('fails when the aggregate score is below thresholds.break', async () => {
const result = await runFixture({ statuses: ['Survived'], threshold: 65 })

expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('below break threshold 65')
await expect(readAggregate(result.cwd)).resolves.toMatchObject({
score: 0,
threshold: 65,
totals: { survived: 1 },
})
})

it('clears stale per-file reports before writing the current run artifacts', async () => {
const result = await runFixture({
beforeRun: (cwd) => writeStaleByFileReport(cwd),
Expand All @@ -139,23 +184,30 @@ describe('batched mutation runner', () => {
async function runFixture({
beforeRun,
childStatus,
childStatuses = {},
files = ['src/example.ts'],
statuses = ['Killed'],
statusesByFile = {},
threshold = 65,
writeReport = true,
}: {
beforeRun?: (cwd: string) => void
childStatus?: number
childStatuses?: Record<string, number>
files?: string[]
statuses?: string[]
statusesByFile?: Record<string, string[]>
threshold?: number | null
writeReport?: boolean
}) {
const cwd = await mkdtemp(join(tmpdir(), 'b2-batched-mutation-'))
tempDirs.push(cwd)
writeConfig(cwd, threshold)
writeConfig(cwd, threshold, files)
beforeRun?.(cwd)

let command = ''
let args: string[] = []
let runIndex = 0
const stdout: string[] = []
const stderr: string[] = []
const exitCode = runner.runBatchedMutation({
Expand All @@ -164,11 +216,15 @@ async function runFixture({
runCommand: (nextCommand, nextArgs, options) => {
command = nextCommand
args = nextArgs
const file = files[runIndex]
runIndex += 1
if (file === undefined) throw new Error('Unexpected extra Stryker invocation')
expect(options.cwd).toBe(cwd)
if (writeReport) writeReportFile(cwd, statuses)
if (childStatus !== undefined) {
if (writeReport) writeReportFile(cwd, file, statusesByFile[file] ?? statuses)
const nextChildStatus = childStatuses[file] ?? childStatus
if (nextChildStatus !== undefined) {
const error = new Error('simulated child failure') as Error & { status: number }
error.status = childStatus
error.status = nextChildStatus
throw error
}
},
Expand All @@ -191,23 +247,23 @@ function writeStaleByFileReport(cwd: string) {
writeFileSync(join(cwd, 'reports/mutation/by-file/src__removed.ts.json'), '{}')
}

function writeConfig(cwd: string, threshold: number | null) {
function writeConfig(cwd: string, threshold: number | null, files: string[]) {
writeFileSync(
join(cwd, 'stryker.conf.json'),
JSON.stringify({
mutate: ['src/example.ts'],
mutate: files,
thresholds: { break: threshold },
}),
)
}

function writeReportFile(cwd: string, statuses: string[]) {
function writeReportFile(cwd: string, file: string, statuses: string[]) {
mkdirSync(join(cwd, 'reports/mutation'), { recursive: true })
writeFileSync(
join(cwd, 'reports/mutation/mutation.json'),
JSON.stringify({
files: {
'src/example.ts': {
[file]: {
mutants: statuses.map((status, index) => ({ id: String(index), status })),
},
},
Expand All @@ -218,3 +274,7 @@ function writeReportFile(cwd: string, statuses: string[]) {
async function readAggregate(cwd: string) {
return JSON.parse(await readFile(join(cwd, 'reports/mutation/aggregate.json'), 'utf8')) as unknown
}

async function readPerFileConfig(cwd: string) {
return JSON.parse(await readFile(join(cwd, runner.PER_FILE_CONFIG), 'utf8')) as unknown
}
81 changes: 57 additions & 24 deletions scripts/run-batched-mutation-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

export const REPORT = 'reports/mutation/mutation.json'
export const PER_FILE_CONFIG = 'reports/mutation/stryker-per-file.conf.json'
export const BY_FILE_DIR = 'reports/mutation/by-file'
export const REPORTERS = 'clear-text,json'
export const STATUS_KEYS = Object.freeze([
Expand Down Expand Up @@ -79,11 +80,12 @@ export function runBatchedMutation({
for (const file of files) {
stdout(`\n=== mutating ${file} ===`)
rmSync(resolve(cwd, REPORT), { force: true })
writePerFileConfig(cwd, config, file)

try {
runCommand(command, strykerArgs(file), { cwd, stdio: 'inherit' })
runCommand(command, strykerArgs(), { cwd, stdio: 'inherit' })
} catch (error) {
childFailures.push({ file, message: childFailureMessage(error) })
childFailures.push({ file, ...childFailureDetails(error) })
}

if (!existsSync(resolve(cwd, REPORT))) {
Expand Down Expand Up @@ -114,12 +116,6 @@ export function runBatchedMutation({
}
writeFileSync(resolve(cwd, 'reports/mutation/aggregate.json'), JSON.stringify(summary, null, 2))

if (childFailures.length > 0) {
stderr('\nOne or more Stryker subprocesses failed even though a report was produced:')
for (const failure of childFailures) stderr(`- ${failure.file}: ${failure.message}`)
return 1
}

if (totals.errors > 0) {
stderr(`\nStryker reported ${totals.errors} errored mutant(s); treating as a hard failure.`)
return 1
Expand All @@ -138,6 +134,16 @@ export function runBatchedMutation({
return 1
}

const hardChildFailures = childFailures.filter((failure) => {
const row = rows.find(({ file }) => file === failure.file)
return !isPerFileThresholdExit(failure, row, threshold)
})
if (hardChildFailures.length > 0) {
stderr('\nOne or more Stryker subprocesses failed even though a report was produced:')
for (const failure of hardChildFailures) stderr(`- ${failure.file}: ${failure.message}`)
return 1
}

if (threshold === null) {
stdout(
`\nAggregate mutation score ${aggregateScore.toFixed(2)}%; no break threshold configured.`,
Expand All @@ -150,18 +156,10 @@ export function runBatchedMutation({
return 0
}

export function strykerArgs(file) {
return [
'exec',
'stryker',
'run',
'--mutate',
file,
'--reporters',
REPORTERS,
'--thresholds.break',
'0',
]
export function strykerArgs() {
// Stryker 9.x thresholds are config-file only. The wrapper writes this
// per-file config with thresholds.break disabled, then gates on the aggregate.
return ['exec', 'stryker', 'run', PER_FILE_CONFIG]
}

export function defaultPnpmCommand(platform = process.platform) {
Expand Down Expand Up @@ -208,12 +206,47 @@ function zero() {
return Object.fromEntries(STATUS_KEYS.map((key) => [key, 0]))
}

function childFailureMessage(error) {
function writePerFileConfig(cwd, config, file) {
mkdirSync(resolve(cwd, 'reports/mutation'), { recursive: true })
const thresholds =
config.thresholds === undefined
? undefined
: { high: 80, low: 60, ...config.thresholds, break: null }
writeFileSync(
resolve(cwd, PER_FILE_CONFIG),
JSON.stringify(
{
...config,
mutate: [file],
reporters: REPORTERS.split(','),
...(thresholds === undefined ? {} : { thresholds }),
},
null,
2,
),
)
}

function childFailureDetails(error) {
if (typeof error === 'object' && error !== null && 'status' in error) {
return `exit status ${error.status ?? 'unknown'}`
return {
message: `exit status ${error.status ?? 'unknown'}`,
status: typeof error.status === 'number' ? error.status : null,
}
}
if (error instanceof Error) return error.message
return String(error)
if (error instanceof Error) return { message: error.message, status: null }
return { message: String(error), status: null }
}

function isPerFileThresholdExit(failure, row, threshold) {
return (
failure.status === 1 &&
threshold !== null &&
row !== undefined &&
row.score < threshold &&
row.errors === 0 &&
row.unknown === 0
)
}

function printAggregate(rows, totals, stdout) {
Expand Down
Loading