diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 2da0c5c5b4..72969f4a42 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -14,11 +14,48 @@ concurrency: cancel-in-progress: true jobs: + # The open pull request map is read in a job that never checks out or runs pull-request + # code, so the token cannot reach anything the contributor controls. Only the filtered map + # crosses into the job that runs the gate. + stacked_map: + name: Resolve the stacked parent + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + outputs: + stacked_map: ${{ steps.read_map.outputs.stacked_map }} + steps: + - name: Read the open pull request heads + id: read_map + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # The fallback must itself be a command: this step runs bash with -e, so a bare '[]' + # is executed as a program name and an API failure would abort the job instead of + # measuring against the event base. + # --jq runs once per page, so a paginated call emits one JSON array per page; the + # gate reads that stream, not a single array. + STACKED_MAP="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" --jq '[.[] | {number: .number, headSha: .head.sha}]')" || STACKED_MAP="$(printf '[]')" + # Publish the whole value as one record from a validated string, and let a failed + # write abort the job: a truncated or skipped output would leave the gate measuring + # against the event base while still reporting success. + case "$STACKED_MAP" in + "["*) ;; + *) echo "::error title=Stacked unit base::The stacked map is not a JSON array" >&2; exit 1 ;; + esac + printf 'stacked_map<> "$GITHUB_OUTPUT" + mutation-diff: name: mutation-diff + needs: stacked_map if: github.event_name == 'merge_group' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 30 + env: + # Only the filtered map crosses into this job; the token stays in the job that read it. + STACKED_MAP: ${{ needs.stacked_map.outputs.stacked_map }} steps: - name: Record merge-queue enforcement if: github.event_name == 'merge_group' @@ -52,7 +89,11 @@ jobs: HEAD_SHA: ${{ github.sha }} run: | BASE_SHA="$(git rev-parse "$HEAD_SHA^1")" - node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" + # The merge commit's second parent is the pull request head. When that head's own + # parent is another open pull request's head, this PR is a stacked unit, so the gate + # measures only this unit's delta instead of every unmerged ancestor. + PR_HEAD_SHA="$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")" + node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" --pr-head "$PR_HEAD_SHA" --stacked-map "$STACKED_MAP" - name: Upload mutation reports id: mutation_report diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 9742879106..4f861af332 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -281,10 +281,53 @@ export function resolvePullRequestBase(repoRoot, baseSha, headSha) { return parents[0] } -export function selectFromGit(repoRoot, baseSha, headSha) { +// A stacked unit branch is built on the previous unit's head, so the pull request's own base (main) +// attributes every unmerged ancestor to this pull request. When the head commit's first parent is the +// head of another open pull request, that parent is the unit's real base and the diff contains only +// this unit's delta. A pull request whose parent is not another pull request's head keeps the event +// base, so a multi-commit pull request is never charged only its last commit. +export function resolveStackedUnitBase(repoRoot, eventBaseSha, prHeadSha, openPullRequests = []) { + validateSha(eventBaseSha, "base SHA") + validateSha(prHeadSha, "pull request head SHA") + const parents = git(repoRoot, ["rev-list", "--parents", "-n", "1", prHeadSha]).trim().split(/\s+/).slice(1) + // A unit is one commit on top of its parent. A head with more than one parent is a merge on the + // unit branch itself, so its diff is not a single unit delta and the event base is kept even when + // one of those parents is an open pull request head. + if (parents.length !== 1) return { baseSha: eventBaseSha, stackedOn: null } + const parentSha = parents[0].toLowerCase() + const parent = openPullRequests.find((pr) => String(pr.headSha).toLowerCase() === parentSha) + if (!parent) return { baseSha: eventBaseSha, stackedOn: null } + return { baseSha: parentSha, stackedOn: parent.number } +} + +// The ci command decides two things from the commit graph: which base to charge this pull request +// to, and which commit to diff against. A stacked unit diffs against its own pull request head, +// because the merge commit also carries whatever main advanced since the parent unit. A plain +// pull request keeps the merge commit as the diff head. +export function resolveCiInvocation(repoRoot, eventBaseSha, mergeCommitSha, prHeadSha, openPullRequests = []) { + const resolved = resolveStackedUnitBase(repoRoot, eventBaseSha, prHeadSha, openPullRequests) + return { baseSha: resolved.baseSha, diffHead: resolved.stackedOn ? prHeadSha : mergeCommitSha, stackedOn: resolved.stackedOn } +} + +// Selection, source reads, and Stryker must all see the same tree. The workflow checks out +// GitHub's merge commit, which also carries whatever main advanced since the parent unit, so for +// a stacked unit the working tree is moved to the unit head before mutation runs; otherwise the +// selectors come from one tree and the mutated source comes from another. +export function alignExecutionTree(repoRoot, diffHeadSha) { + validateSha(diffHeadSha, "diff head SHA") + const current = git(repoRoot, ["rev-parse", "HEAD"]).trim().toLowerCase() + const wanted = String(diffHeadSha).toLowerCase() + if (current === wanted) return false + git(repoRoot, ["checkout", "--quiet", wanted]) + return true +} + +export function selectFromGit(repoRoot, baseSha, headSha, options = {}) { validateSha(baseSha, "base SHA") validateSha(headSha, "head SHA") - baseSha = resolvePullRequestBase(repoRoot, baseSha, headSha) + // A stacked base was resolved from the pull request head, not from the merge commit, so it must + // survive: re-deriving it from the merge commit would charge the whole unmerged chain to this unit. + if (!options.preserveBase) baseSha = resolvePullRequestBase(repoRoot, baseSha, headSha) const mergeBase = git(repoRoot, ["merge-base", baseSha, headSha]).trim() const nameStatus = git(repoRoot, ["diff", "--name-status", "-z", "--find-renames", `${mergeBase}...${headSha}`]) const entries = parseNameStatus(nameStatus) @@ -566,12 +609,17 @@ export function formatBlockingMutants(blockingMutants, packageRoot) { } export function formatSummary(rows, advisories, manifest = {}) { - const lines = [ - "## Changed-code mutation testing", - "", + const lines = ["## Changed-code mutation testing", ""] + if (manifest.stackedOn) { + lines.push( + `Stacked unit: measured against the head of parent PR #${manifest.stackedOn}, not the event base.`, + "", + ) + } + lines.push( "| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Result |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", - ] + ) for (const row of rows) { lines.push( `| ${row.id} | ${row.changedLines} | ${row.valid} | ${row.killed} | ${row.timeout} | ${row.survived} | ${row.noCoverage} | ${row.result} |`, @@ -796,18 +844,83 @@ function argument(name) { return index === -1 ? undefined : process.argv[index + 1] } +// The workflow passes the open pull requests as JSON of { number, headSha }. gh api --paginate +// emits one array per page, so the map can be a stream of arrays rather than one document; accept +// both shapes. An unparsable map must not change the gate's base, so it degrades to the event base +// instead of throwing. +export function parseStackedMap(value) { + if (!value) return [] + // gh api --paginate emits one JSON array per page, so the input is a stream of arrays rather + // than a single array. Consume the whole stream: if any part of it cannot be parsed, the map + // is not trustworthy as a whole and the caller must fall back to the event base instead of + // keeping a partial entry that could select a parent base. + const text = String(value) + const entries = [] + let index = 0 + while (index < text.length) { + while (index < text.length && /[\s,]/.test(text[index])) index += 1 + if (index >= text.length) break + if (text[index] !== "[") return [] + let depth = 0 + let inString = false + let end = -1 + for (let i = index; i < text.length; i++) { + const char = text[i] + if (inString) { + if (char === "\\") { i += 1; continue } + if (char === "\"") inString = false + continue + } + if (char === "\"") { inString = true; continue } + if (char === "[") depth += 1 + else if (char === "]") { + depth -= 1 + if (depth === 0) { end = i; break } + } + } + if (depth !== 0 || end < 0) return [] + let parsed + try { + parsed = JSON.parse(text.slice(index, end + 1)) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + entries.push(...parsed.filter((entry) => entry && entry.number && /^[0-9a-f]{40}$/i.test(String(entry.headSha)))) + index = end + 1 + } + return entries +} + function main() { const command = process.argv[2] if (command !== "ci") - throw new Error("Usage: node scripts/stryker-diff.mjs ci --base --head [--reports ]") + throw new Error( + "Usage: node scripts/stryker-diff.mjs ci --base --head [--pr-head ] [--reports ] [--stacked-map ]", + ) const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const baseSha = argument("--base") const headSha = argument("--head") if (!baseSha || !headSha) throw new Error("--base and --head are required") + const openPullRequests = parseStackedMap(argument("--stacked-map")) + // --pr-head is the pull request head itself. The workflow runs on GitHub's merge commit, whose + // first parent is the base tip, so the merge commit cannot identify the stacked unit; its second + // parent can. A plain pull request keeps the merge commit as the diff head; a stacked unit + // uses its own head, and the working tree is aligned to that head below. + const prHeadSha = argument("--pr-head") ?? headSha + const invocation = resolveCiInvocation(repoRoot, baseSha, headSha, prHeadSha, openPullRequests) + if (invocation.stackedOn) console.log(`Stacked unit: measured against the head of parent PR #${invocation.stackedOn}, not the event base.`) + if (invocation.stackedOn && alignExecutionTree(repoRoot, invocation.diffHead)) { + console.log(`Aligned the working tree to the diff head ${invocation.diffHead.slice(0, 12)} so selection, source reads, and mutation run on the same tree.`) + } + const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation") - const manifest = selectFromGit(repoRoot, baseSha, headSha) + const manifest = { + ...selectFromGit(repoRoot, invocation.baseSha, invocation.diffHead, { preserveBase: Boolean(invocation.stackedOn) }), + stackedOn: invocation.stackedOn, + } if (manifest.packages.length === 0) { appendSummary([], manifest.advisories, manifest) console.log("No changed executable lines in mutation-tested packages; mutation testing is not applicable.") diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 22284e598d..e8185bcbfd 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -25,6 +25,10 @@ import { parseNameStatus, parseVitestTestFiles, preferDirectTestFiles, + parseStackedMap, + alignExecutionTree, + resolveCiInvocation, + resolveStackedUnitBase, resolveStrykerTempDir, resolveVitestBinary, shouldUseVitestRelated, @@ -58,10 +62,41 @@ describe("mutation testing workflow", () => { assert.ok(workflow.includes("HEAD_SHA: ${{ github.sha }}")) assert.ok(!workflow.includes("HEAD_SHA: ${{ github.event.pull_request.head.sha }}")) assert.ok(workflow.includes('BASE_SHA="$(git rev-parse "$HEAD_SHA^1")"')) + assert.ok(workflow.includes("pull-requests: read")) + assert.ok(workflow.includes('PR_HEAD_SHA="$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")"')) + assert.ok(workflow.includes('--stacked-map "$STACKED_MAP"')) + assert.ok(workflow.includes('--pr-head "$PR_HEAD_SHA"')) + // The open pull request map must cover every page, otherwise a parent unit beyond the first + // page is missing and the gate charges the whole unmerged chain again. + // gh api rejects --slurp together with --jq, so pagination has to keep the per-page filter + // and the parser has to accept the resulting stream of page arrays. + assert.ok(workflow.includes("gh api --paginate")) + assert.ok(!workflow.includes("--slurp")) + assert.ok(workflow.includes("[.[] | {number: .number, headSha: .head.sha}]")) + // The fallback has to be a command: these steps run bash with -e, so a bare '[]' is executed + // as a program name instead of producing JSON. + assert.ok(workflow.includes("printf '[]'")) assert.ok(!workflow.includes("github.event.pull_request.base.sha")) assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) assert.ok(workflow.includes("open the package's mutation.html file")) assert.ok(workflow.includes("Enforce executable-line scope and run advisory mutation testing")) + // The gate hands its environment to the Vitest discovery subprocesses, so the token has to stay + // in a job that never checks out or runs pull-request code; only the filtered map crosses over. + const gateJob = workflow.slice(workflow.indexOf(" mutation-diff:")) + assert.ok(!gateJob.includes("GH_TOKEN")) + assert.ok(workflow.includes("STACKED_MAP: ${{ needs.stacked_map.outputs.stacked_map }}")) + const mapJob = workflow.slice(workflow.indexOf(" stacked_map:"), workflow.indexOf(" mutation-diff:")) + assert.ok(mapJob.includes("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}")) + assert.ok(!mapJob.includes("actions/checkout")) + assert.ok(!mapJob.includes("setup-node-pnpm")) + assert.ok(!mapJob.includes("pnpm test:mutation-ci")) + assert.ok(workflow.includes("needs: stacked_map")) + // The job output is only real if the step it reads from carries that id; without it the map is + // always an empty string and the gate silently falls back to the event base. + assert.ok(mapJob.includes("id: read_map")) + // Publish as one record from a validated value, and do not continue past a failed write. + assert.ok(mapJob.includes("printf 'stacked_map< { }) }) +describe("stacked unit base resolution", () => { + const createSyntheticStack = () => { + const repository = fs.mkdtempSync(path.join(os.tmpdir(), "mutation-stack-")) + // Setup can fail halfway (a missing git config, a failed commit), and the callers only get the + // repository path back on success, so cleanup has to happen here or the temp directory leaks. + try { + const run = (...args) => execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim() + const write = (filePath, contents) => { + fs.mkdirSync(path.join(repository, path.dirname(filePath)), { recursive: true }) + fs.writeFileSync(path.join(repository, filePath), contents) + } + + run("init", "--quiet", "--initial-branch", "main") + run("config", "user.email", "gate@example.com") + run("config", "user.name", "Gate") + run("config", "commit.gpgsign", "false") + + write("packages/core/src/unrelated.ts", "export const unrelated = () => 1\n") + run("add", ".") + run("commit", "--quiet", "-m", "initial") + const eventBaseSha = run("rev-parse", "HEAD") + + run("checkout", "--quiet", "-b", "unit-1") + write("packages/core/src/unit1.ts", "export const unit1 = () => 1\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 1") + const parentSha = run("rev-parse", "HEAD") + + // The stacked unit is one commit on top of the parent unit's head. + write("packages/core/src/unit2.ts", "export const unit2 = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 2") + const childSha = run("rev-parse", "HEAD") + + return { repository, eventBaseSha, parentSha, childSha, run, write } + } catch (error) { + fs.rmSync(repository, { recursive: true, force: true }) + throw error + } + } + + it("measures a stacked unit against the parent pull request head", () => { + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const resolved = resolveStackedUnitBase(repository, eventBaseSha, childSha, [{ number: 1, headSha: parentSha }]) + assert.equal(resolved.baseSha, parentSha) + assert.equal(resolved.stackedOn, 1) + + const manifest = selectFromGit(repository, resolved.baseSha, childSha) + assert.deepEqual( + manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit2.ts"], + ) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("preserves the resolved stacked base when selection runs on the merge commit", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + // GitHub runs the gate on its own merge commit: first parent is the base tip, second + // parent is the pull request head. + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + write("packages/core/src/unrelated.ts", "export const unrelated = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + const resolved = resolveStackedUnitBase(repository, advancedBase, childSha, [{ number: 1, headSha: parentSha }]) + assert.equal(resolved.baseSha, parentSha) + assert.equal(resolved.stackedOn, 1) + + // Without preserving the resolved base, selection re-derives it from the merge commit and + // charges the whole unmerged chain to this unit. + const charged = selectFromGit(repository, resolved.baseSha, mergeSha) + assert.deepEqual( + charged.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"], + ) + + // With the base preserved, the unit is measured against its own head: the merge commit also + // carries the base advance, which is not this unit's delta. + const unit = selectFromGit(repository, resolved.baseSha, childSha, { preserveBase: true }) + assert.deepEqual( + unit.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit2.ts"], + ) + + // A non-stacked pull request still measures against the merge commit, so the base actually + // merged into is used and the base advance is not charged to the pull request. + const plain = selectFromGit(repository, advancedBase, mergeSha, { preserveBase: true }) + assert.deepEqual( + plain.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"], + ) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("keeps the event base when the parent commit is not another pull request head", () => { + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const resolved = resolveStackedUnitBase(repository, eventBaseSha, childSha, []) + assert.equal(resolved.baseSha, eventBaseSha) + assert.equal(resolved.stackedOn, null) + + // A multi-commit pull request must not be charged only its last commit. + const manifest = selectFromGit(repository, resolved.baseSha, childSha) + assert.deepEqual( + manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)).sort(), + ["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"], + ) + void parentSha + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("degrades to the event base for an unparsable stacked map", () => { + assert.deepEqual(parseStackedMap("not json"), []) + assert.deepEqual(parseStackedMap('{"number": 1}'), []) + assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"abc\"}]"), []) + assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"" + "a".repeat(40) + "\"}]"), [ + { number: 1, headSha: "a".repeat(40) }, + ]) + + // gh api --paginate emits one array per page, so a parent unit on page 2 arrives as a second + // document rather than inside the first array. + assert.deepEqual( + parseStackedMap( + "[{\"number\": 1, \"headSha\": \"" + "b".repeat(40) + "\"}]" + "[{\"number\": 2, \"headSha\": \"" + "c".repeat(40) + "\"}]", + ), + [ + { number: 1, headSha: "b".repeat(40) }, + { number: 2, headSha: "c".repeat(40) }, + ], + ) + // Trailing content cannot be consumed as a page, so the whole map is rejected rather than + // keeping a partial entry that could select a parent base. + assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"" + "d".repeat(40) + "\"}]garbage"), []) + assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"" + "d".repeat(40) + "\"}] [{\"number\": 2"), []) + }) + + it("the ci command picks the diff head from the commit graph", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + write("packages/core/src/unrelated.ts", "export const unrelated = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + // Stacked: base is the parent PR head and the diff head is the PR head, not the merge + // commit, so the base advance is not charged to this unit. + const stacked = resolveCiInvocation(repository, advancedBase, mergeSha, childSha, [{ number: 1, headSha: parentSha }]) + assert.equal(stacked.baseSha, parentSha) + assert.equal(stacked.diffHead, childSha) + assert.equal(stacked.stackedOn, 1) + + // Non-stacked: the merge commit stays the diff head and the base stays the event base. + const plain = resolveCiInvocation(repository, advancedBase, mergeSha, childSha, []) + assert.equal(plain.baseSha, advancedBase) + assert.equal(plain.diffHead, mergeSha) + assert.equal(plain.stackedOn, null) + + // --pr-head defaults to --head, so a plain pull request keeps the same decision. + const sameHead = resolveCiInvocation(repository, advancedBase, mergeSha, mergeSha, []) + assert.equal(sameHead.diffHead, mergeSha) + assert.equal(sameHead.stackedOn, null) + + // A unit branch that is itself a merge commit is not a single unit delta, so the fallback + // stays the event base even when one of its parents is an open pull request head. + run("checkout", "--quiet", "-b", "unit-3", advancedBase) + write("packages/core/src/unit3.ts", "export const unit3 = () => 3\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 3") + run("merge", "--no-ff", "--quiet", "-m", "unit merge", parentSha) + const multiParentHead = run("rev-parse", "HEAD") + assert.equal(run("rev-list", "--parents", "-n", "1", multiParentHead).trim().split(/\s+/).slice(1).length, 2) + const multi = resolveStackedUnitBase(repository, advancedBase, multiParentHead, [{ number: 1, headSha: parentSha }]) + assert.equal(multi.baseSha, advancedBase) + assert.equal(multi.stackedOn, null) + assert.equal(resolveCiInvocation(repository, advancedBase, multiParentHead, multiParentHead, [{ number: 1, headSha: parentSha }]).stackedOn, null) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("aligns the working tree to the diff head so selection and mutation see one tree", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + // The base advance changes a file, so the merge tree differs from the unit tree. The + // selectors are derived from the unit tree, so mutation has to run on that same tree. + write("packages/core/src/unrelated.ts", "export const unrelated = () => 99\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + const invocation = resolveCiInvocation(repository, advancedBase, mergeSha, childSha, [{ number: 1, headSha: parentSha }]) + assert.equal(invocation.diffHead, childSha) + assert.equal(run("rev-parse", "HEAD").toLowerCase(), mergeSha.toLowerCase()) + + assert.equal(alignExecutionTree(repository, invocation.diffHead), true) + assert.equal(run("rev-parse", "HEAD").toLowerCase(), childSha.toLowerCase()) + // The tree Stryker would mutate is now the unit tree, not the merge tree. + assert.equal(fs.readFileSync(path.join(repository, "packages/core/src/unit2.ts"), "utf8").replace(/\r/g, ""), "export const unit2 = () => 2\n") + // Aligning again is a no-op. + assert.equal(alignExecutionTree(repository, invocation.diffHead), false) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) +}) + describe("parseNameStatus", () => { it("parses added, modified, and renamed paths", () => { assert.deepEqual( @@ -924,6 +1187,17 @@ describe("failure output", () => { assert.equal(new Set(summary.match(/Package\dMutator\d+/g)).size, 6 * MAX_MUTANTS) assert.ok(Buffer.byteLength(summary) < 1024 * 1024) }) + + it("names the parent pull request when the unit was measured against a stacked base", () => { + const rows = [{ id: "extension", changedLines: 12, valid: 3, killed: 3, timeout: 0, survived: 0, noCoverage: 0, blocking: [], result: "Pass" }] + const summary = formatSummary(rows, [], { stackedOn: 1914 }) + assert.ok(summary.includes("Stacked unit: measured against the head of parent PR #1914, not the event base.")) + + // A plain pull request has no stacked base, so the notice must not appear. + const plain = formatSummary(rows, [], { stackedOn: null }) + assert.ok(!plain.includes("Stacked unit")) + assert.ok(plain.includes("## Changed-code mutation testing")) + }) }) describe("report evaluation", () => {