Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
test: SwapSeam helper + advisory gremlins mutation workflow#306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| name: Mutation (gremlins) | ||
| # ADVISORY, manual-only mutation testing with go-gremlins (#295). | ||
| # | ||
| # Mutation testing answers the question coverage can't: "would the tests | ||
| # actually FAIL if this line's logic flipped?" Several real gaps in this repo | ||
| # were found by running gremlins by hand and pinning the survivors (#262, | ||
| # #263, #264). This workflow formalizes that ritual: | ||
| # | ||
| # - workflow_dispatch ONLY — never on PRs or pushes. Mutation runs are | ||
| # slow (each mutant recompiles + reruns the package tests) and their | ||
| # output needs human triage, so they must never gate a merge. | ||
| # - ONE package per run (the `package` input). Whole-module runs take | ||
| # hours and produce an untriageable wall of survivors. | ||
| # - Findings are NOT failures. A LIVED mutant means "no test would catch | ||
| # this logic flip" — triage it and file an issue (see CONTRIBUTING.md | ||
| # "Mutation testing" for the ritual: which survivors matter, which are | ||
| # noise, and how to write the pinning test). | ||
| # | ||
| # The job's conclusion only reflects tool/test health (a broken suite fails | ||
| # the run); lived mutants never do — no --threshold-* flags are set. | ||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| package: | ||
| description: "Package to mutate, relative to the repo root (one per run), e.g. internal/push" | ||
| required: true | ||
| default: "internal/push" | ||
| type: string | ||
| timeout_coefficient: | ||
| description: "gremlins --timeout-coefficient (raise if mutants report TIMED OUT instead of KILLED/LIVED)" | ||
| required: false | ||
| default: "3" | ||
| type: string | ||
| permissions: | ||
| contents: read | ||
| concurrency: | ||
| group: mutation-${{ inputs.package }} | ||
| cancel-in-progress: false | ||
| jobs: | ||
| mutate: | ||
| timeout-minutes: 45 | ||
| name: Mutation (${{ inputs.package }}) | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - name: Set up Go | ||
| uses: actions/setup-go@v5 | ||
| with: | ||
| go-version-file: go.mod | ||
| cache: true | ||
| # Pinned for reproducibility, same policy as the lint tools in | ||
| # build.yml. v0.5.0 is the latest gremlins release. | ||
| - name: Install gremlins | ||
| run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.5.0 | ||
| # The inputs are typed through env vars and validated before use — | ||
| # never interpolate ${{ inputs.* }} straight into the script. | ||
| - name: Run mutation testing (advisory — lived mutants do not fail the job) | ||
| env: | ||
| PKG: ${{ inputs.package }} | ||
| TCOEF: ${{ inputs.timeout_coefficient }} | ||
| run: | | ||
| # The implicit default shell is `bash -e {0}` (no pipefail), so a | ||
| # failure in `gremlins | tee` below would otherwise be masked by | ||
| # tee's exit code. pipefail makes tool/test health fail the job, as | ||
| # this workflow's contract promises. | ||
| set -o pipefail | ||
| case "$PKG" in | ||
| (*[!a-zA-Z0-9/_.-]*|""|/*|*..*) | ||
| echo "::error::invalid package path '$PKG' (want e.g. internal/push)"; exit 1;; | ||
| esac | ||
| case "$TCOEF" in | ||
| (""|*[!0-9]*) | ||
| echo "::error::invalid timeout_coefficient '$TCOEF' (want a positive integer)"; exit 1;; | ||
| (*[1-9]*) ;; # all digits with at least one non-zero -> positive | ||
| (*) | ||
| echo "::error::invalid timeout_coefficient '$TCOEF' (want a positive integer greater than 0)"; exit 1;; | ||
| esac | ||
| if [ ! -d "$PKG" ]; then | ||
| echo "::error::no such package directory: $PKG"; exit 1 | ||
| fi | ||
| gremlins unleash "./$PKG" \ | ||
| --timeout-coefficient "$TCOEF" \ | ||
| --output gremlins-report.json \ | ||
| | tee gremlins.log | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| - name: Summary — survivors to triage | ||
| if: always() | ||
| run: | | ||
| { | ||
| echo "## Mutation report: \`${PKG:-?}\`" | ||
| echo | ||
| echo '```' | ||
| grep -E "^\s+(LIVED|TIMED OUT)" gremlins.log 2>/dev/null || echo "no LIVED or TIMED OUT mutants — nothing to triage" | ||
| echo '```' | ||
| echo | ||
| tail -5 gremlins.log 2>/dev/null || true | ||
| echo | ||
| echo "Triage ritual: CONTRIBUTING.md > Mutation testing. Survivors worth killing become issues titled 'test(<pkg>): pin <behavior> (mutation survivor)'." | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| env: | ||
| PKG: ${{ inputs.package }} | ||
| - name: Upload machine-readable report | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: gremlins-report | ||
| path: | | ||
| gremlins-report.json | ||
| gremlins.log | ||
| if-no-files-found: warn | ||
| retention-days: 30 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // Package testutil holds small helpers shared across the CLI's test suites. | ||
| // | ||
| // Test-support only: production code must never import this package. It lives | ||
| // under internal/ like everything else, so the compiler can't stop a stray | ||
| // import — code review must. | ||
| package testutil | ||
| import "testing" | ||
| // SwapSeam replaces the value behind ptr — typically a package-level function | ||
| // variable used as a test seam (watchJobFn, newAPIClient, helm.Runner, …), but | ||
| // any swappable package var (e.g. a timeout) works — with stub for the | ||
| // duration of the test, restoring the original via t.Cleanup. | ||
| // | ||
| // It replaces the hand-rolled three-line save/stub/restore block: | ||
| // | ||
| // orig := watchJobFn | ||
| // watchJobFn = stub | ||
| // t.Cleanup(func() { watchJobFn = orig }) | ||
| // | ||
| // with a single call: | ||
| // | ||
| // testutil.SwapSeam(t, &watchJobFn, stub) | ||
| // | ||
| // Cleanup ordering follows t.Cleanup semantics (LIFO), so nested swaps of the | ||
| // same seam restore correctly. NOT safe for use with t.Parallel() siblings | ||
| // that share the same seam — package-level seams never are. | ||
| func SwapSeam[T any](t testing.TB, ptr *T, stub T) { | ||
| t.Helper() | ||
| orig := *ptr | ||
| *ptr = stub | ||
| t.Cleanup(func() { *ptr = orig }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package testutil | ||
| import "testing" | ||
| // The seam must hold the stub for the test body and be restored by Cleanup — | ||
| // including nested swaps of the SAME seam, which must unwind LIFO back to the | ||
| // original. | ||
| func TestSwapSeam_SwapsAndRestores(t *testing.T) { | ||
| seam := func() string { return "original" } | ||
| t.Run("inner", func(t *testing.T) { | ||
| SwapSeam(t, &seam, func() string { return "outer-stub" }) | ||
| if got := seam(); got != "outer-stub" { | ||
| t.Fatalf("seam() = %q after swap, want %q", got, "outer-stub") | ||
| } | ||
| t.Run("nested", func(t *testing.T) { | ||
| SwapSeam(t, &seam, func() string { return "inner-stub" }) | ||
| if got := seam(); got != "inner-stub" { | ||
| t.Fatalf("seam() = %q after nested swap, want %q", got, "inner-stub") | ||
| } | ||
| }) | ||
| // The nested subtest's Cleanup has run: back to the outer stub. | ||
| if got := seam(); got != "outer-stub" { | ||
| t.Fatalf("seam() = %q after nested cleanup, want %q (LIFO restore broken)", got, "outer-stub") | ||
| } | ||
| }) | ||
| if got := seam(); got != "original" { | ||
| t.Fatalf("seam() = %q after all cleanups, want %q (restore broken)", got, "original") | ||
| } | ||
| } | ||
| // Non-function seams (durations, limits) are first-class too — T is any. | ||
| func TestSwapSeam_NonFunctionValue(t *testing.T) { | ||
| limit := 100 | ||
| t.Run("inner", func(t *testing.T) { | ||
| SwapSeam(t, &limit, 5) | ||
| if limit != 5 { | ||
| t.Fatalf("limit = %d after swap, want 5", limit) | ||
| } | ||
| }) | ||
| if limit != 100 { | ||
| t.Fatalf("limit = %d after cleanup, want 100", limit) | ||
| } | ||
| } |
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.