scripts/mutation-check reads a JSON list of breaks, applies each to the tree one at a time, restores the file byte for byte, and reports CAUGHT, MISSED, WRONG-MESSAGE, NOT-FOUND, SKIPPED or CONTROL-FAILED for each. New tests: TestApplyAndRestoreAreByteExact, TestOldTextMustOccurOnce, TestSidePicksPatternAndMessage, TestARecordWithASideRunsOnlyThere, TestOutcomes, TestStatelessListStopsOnAFailingControl, TestControlRunsOncePerDistinctTest, TestStatefulListPreparesAndControlsEachBreak, TestStatefulControlFailureMarksOnlyThatBreak, TestPrepareFailureStopsTheList, TestCommandRecordsAreClassifiedByExitAndText, TestCommandRecordReadsTheInspectedFileAndNeverAStaleOne, TestCommandControlFailsWhenTheTextIsAlreadyThere, TestSummarizeExitsOneUnlessEveryBreakIsCaught.
360 lines
11 KiB
Go
360 lines
11 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fake stands in for the processes a list starts. test and command answer
|
|
// from the file the break edited, so a result depends on whether the break is
|
|
// applied.
|
|
type fake struct {
|
|
root string
|
|
test func(pkg, run, file string) result
|
|
command func(cmd string) result
|
|
ran []string // "prepare", "test <run>", "cmd <cmd>" in call order
|
|
prepErr error
|
|
}
|
|
|
|
func (f *fake) Prepare(_ context.Context, cmd string) error {
|
|
f.ran = append(f.ran, "prepare")
|
|
return f.prepErr
|
|
}
|
|
|
|
func (f *fake) Test(_ context.Context, pkg, run string) (result, error) {
|
|
f.ran = append(f.ran, "test "+run)
|
|
data, err := os.ReadFile(filepath.Join(f.root, "subject.txt"))
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return f.test(pkg, run, string(data)), nil
|
|
}
|
|
|
|
func (f *fake) Command(_ context.Context, cmd string) (result, error) {
|
|
f.ran = append(f.ran, "cmd "+cmd)
|
|
return f.command(cmd), nil
|
|
}
|
|
|
|
const pass = "=== RUN TestX\n--- PASS: TestX (0.00s)\nPASS\n"
|
|
|
|
// failing is a test run that fails with msg.
|
|
func failing(msg string) result {
|
|
return result{out: "=== RUN TestX\n x_test.go:1: " + msg + "\n--- FAIL: TestX (0.00s)\nFAIL\n", exit: 1}
|
|
}
|
|
|
|
// setup writes subject.txt and returns a runner over a fake whose tests fail
|
|
// with "boom" when the file no longer says "good".
|
|
func setup(t *testing.T, side string) (*runner, *fake, *bytes.Buffer) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "subject.txt"), []byte("good good-and-more"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fake{root: root}
|
|
f.test = func(_, _, file string) result {
|
|
if strings.HasPrefix(file, "good ") {
|
|
return result{out: pass}
|
|
}
|
|
return failing("boom")
|
|
}
|
|
f.command = func(string) result { return result{} }
|
|
var out bytes.Buffer
|
|
return &runner{ex: f, root: root, side: side, out: &out}, f, &out
|
|
}
|
|
|
|
func subject(t *testing.T, r *runner) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(r.root, "subject.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func testBreak(name, old string) record {
|
|
return record{Name: name, File: "subject.txt", Old: old, New: "bad", Pkg: "./p",
|
|
RunOld: "^TestOld$", RunNew: "^TestNew$", WantOld: "boom", WantNew: "boom"}
|
|
}
|
|
|
|
func TestApplyAndRestoreAreByteExact(t *testing.T) {
|
|
r, _, _ := setup(t, "new")
|
|
before := subject(t, r)
|
|
counts, err := r.runList(context.Background(), list{Breaks: []record{testBreak("first", "good ")}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[caught] != 1 {
|
|
t.Fatalf("counts = %v, want one caught", counts)
|
|
}
|
|
if got := subject(t, r); got != before {
|
|
t.Errorf("file after the run = %q, want %q", got, before)
|
|
}
|
|
}
|
|
|
|
func TestOldTextMustOccurOnce(t *testing.T) {
|
|
for _, old := range []string{"absent", "good"} { // "good" occurs twice
|
|
r, _, _ := setup(t, "new")
|
|
before := subject(t, r)
|
|
counts, err := r.runList(context.Background(), list{Breaks: []record{testBreak("b", old)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[notFound] != 1 {
|
|
t.Errorf("old %q: counts = %v, want one %s", old, counts, notFound)
|
|
}
|
|
if got := subject(t, r); got != before {
|
|
t.Errorf("old %q: the file changed to %q", old, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSidePicksPatternAndMessage(t *testing.T) {
|
|
r, f, _ := setup(t, "old")
|
|
b := testBreak("b", "good ")
|
|
b.RunOld, b.WantOld = "^TestOld$", "boom"
|
|
b.RunNew, b.WantNew = "^TestNew$", "never printed"
|
|
counts, err := r.runList(context.Background(), list{Breaks: []record{b}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[caught] != 1 {
|
|
t.Errorf("old side counts = %v, want one caught", counts)
|
|
}
|
|
for _, c := range f.ran {
|
|
if c == "test ^TestNew$" {
|
|
t.Errorf("the old side ran the new pattern: %v", f.ran)
|
|
}
|
|
}
|
|
|
|
r, _, _ = setup(t, "new")
|
|
counts, err = r.runList(context.Background(), list{Breaks: []record{b}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[wrongMessage] != 1 {
|
|
t.Errorf("new side counts = %v, want one %s", counts, wrongMessage)
|
|
}
|
|
}
|
|
|
|
func TestARecordWithASideRunsOnlyThere(t *testing.T) {
|
|
r, f, out := setup(t, "new")
|
|
oldOnly := testBreak("old only", "good ")
|
|
oldOnly.Side = "old"
|
|
newOnly := testBreak("new only", "good ")
|
|
newOnly.Side = "new"
|
|
counts, err := r.runList(context.Background(), list{Breaks: []record{oldOnly, newOnly}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[caught] != 1 || strings.Contains(out.String(), "old only") {
|
|
t.Errorf("counts = %v, output %q: want only the new record", counts, out.String())
|
|
}
|
|
if len(f.ran) != 2 { // one control, one break
|
|
t.Errorf("ran %v, want a control and one break", f.ran)
|
|
}
|
|
}
|
|
|
|
func TestOutcomes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
res result
|
|
want string
|
|
}{
|
|
{"fails with the message", failing("boom"), caught},
|
|
{"fails with another message", failing("other"), wrongMessage},
|
|
{"passes", result{out: pass}, missed},
|
|
{"skips", result{out: "=== RUN TestX\n--- SKIP: TestX (0.00s)\nPASS\n"}, skipped},
|
|
{"names no test", result{out: "ok \tp\t0.1s [no tests to run]\n"}, skipped},
|
|
{"does not build", result{out: "FAIL\tp [build failed]\nboom\n", exit: 1}, caught},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, _ := classifyTest(tc.res, "boom")
|
|
if got != tc.want {
|
|
t.Errorf("outcome = %s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatelessListStopsOnAFailingControl(t *testing.T) {
|
|
for name, res := range map[string]result{
|
|
"fails": failing("boom"),
|
|
"skips": {out: "--- SKIP: TestX (0.00s)\n"},
|
|
"prints": {out: pass + "boom\n"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
f.test = func(_, _, _ string) result { return res }
|
|
_, err := r.runList(context.Background(), list{Breaks: []record{testBreak("b", "good ")}})
|
|
if err == nil || !strings.Contains(err.Error(), "control run") {
|
|
t.Fatalf("err = %v, want a control run failure", err)
|
|
}
|
|
if got := subject(t, r); got != "good good-and-more" {
|
|
t.Errorf("a break was applied: %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestControlRunsOncePerDistinctTest(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
_, err := r.runList(context.Background(), list{Breaks: []record{
|
|
testBreak("one", "good "), testBreak("two", "-and-more"),
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.Join(f.ran, ","); got != "test ^TestNew$,test ^TestNew$,test ^TestNew$" {
|
|
t.Errorf("ran %s, want one control and two breaks", got)
|
|
}
|
|
}
|
|
|
|
func TestStatefulListPreparesAndControlsEachBreak(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
counts, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{
|
|
testBreak("one", "good "), testBreak("two", "-and-more"),
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// prepare, control, prepare, break; twice; then prepare on the restored tree.
|
|
want := "prepare,test ^TestNew$,prepare,test ^TestNew$,prepare,test ^TestNew$,prepare,test ^TestNew$,prepare"
|
|
if got := strings.Join(f.ran, ","); got != want {
|
|
t.Errorf("ran %s,\nwant %s", got, want)
|
|
}
|
|
if counts[caught] != 1 || counts[missed] != 1 {
|
|
t.Errorf("counts = %v, want one caught and one missed (the second edit leaves the file good)", counts)
|
|
}
|
|
}
|
|
|
|
func TestStatefulControlFailureMarksOnlyThatBreak(t *testing.T) {
|
|
controls := map[string]result{
|
|
"fails": failing("unrelated"),
|
|
"skips": {out: "=== RUN TestX\n--- SKIP: TestX (0.00s)\nPASS\n"},
|
|
}
|
|
for name, control := range controls {
|
|
t.Run(name, func(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
calls := 0
|
|
f.test = func(_, _, file string) result {
|
|
calls++
|
|
if calls == 1 { // the first break's control run
|
|
return control
|
|
}
|
|
if strings.HasPrefix(file, "good ") {
|
|
return result{out: pass}
|
|
}
|
|
return failing("boom")
|
|
}
|
|
counts, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{
|
|
testBreak("one", "good "), testBreak("two", "good "),
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[controlFailed] != 1 || counts[caught] != 1 {
|
|
t.Errorf("counts = %v, want one %s and one caught", counts, controlFailed)
|
|
}
|
|
if got := subject(t, r); got != "good good-and-more" {
|
|
t.Errorf("file = %q, want the original", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareFailureStopsTheList(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
f.prepErr = os.ErrInvalid
|
|
_, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{testBreak("b", "good ")}})
|
|
if err == nil {
|
|
t.Fatal("a failing prepare did not stop the list")
|
|
}
|
|
}
|
|
|
|
func TestCommandRecordsAreClassifiedByExitAndText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rec record
|
|
res result
|
|
want string
|
|
}{
|
|
{"exit and text", record{Exit: 1}, result{out: "found: boom", exit: 1}, caught},
|
|
{"right exit, no text", record{Exit: 1}, result{out: "other", exit: 1}, wrongMessage},
|
|
{"exits 0 when it should fail", record{Exit: 1}, result{out: "boom"}, missed},
|
|
{"another failure", record{Exit: 1}, result{out: "boom", exit: 2}, wrongMessage},
|
|
{"exit 0 with text", record{Exit: 0}, result{out: "boom"}, caught},
|
|
{"exit 0 without text", record{Exit: 0}, result{out: "clean"}, missed},
|
|
{"exit 0 wanted, command failed", record{Exit: 0}, result{out: "boom", exit: 1}, wrongMessage},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r, _, _ := setup(t, "new")
|
|
tc.rec.Cmd = "make x"
|
|
tc.rec.WantNew = "boom"
|
|
got, _ := r.classify(tc.rec, tc.res, tc.res.out)
|
|
if got != tc.want {
|
|
t.Errorf("outcome = %s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandRecordReadsTheInspectedFileAndNeverAStaleOne(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
inspect := filepath.Join(r.root, "changes.txt")
|
|
if err := os.WriteFile(inspect, []byte("boom from an earlier run"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := record{Name: "screens", File: "subject.txt", Old: "good ", New: "bad", Cmd: "make screens",
|
|
Exit: 0, Inspect: "changes.txt", WantNew: "boom"}
|
|
f.command = func(string) result {
|
|
data, err := os.ReadFile(inspect)
|
|
if err == nil { // the stale file survived: the tool did not remove it
|
|
t.Errorf("stale inspected file present: %q", data)
|
|
}
|
|
if strings.HasPrefix(subject(t, r), "bad") {
|
|
if err := os.WriteFile(inspect, []byte("boom"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return result{}
|
|
}
|
|
counts, err := r.runList(context.Background(), list{Breaks: []record{b}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counts[caught] != 1 {
|
|
t.Errorf("counts = %v, want the file's text to catch the break", counts)
|
|
}
|
|
}
|
|
|
|
func TestCommandControlFailsWhenTheTextIsAlreadyThere(t *testing.T) {
|
|
r, f, _ := setup(t, "new")
|
|
f.command = func(string) result { return result{out: "boom"} }
|
|
b := record{Name: "lint", File: "subject.txt", Old: "good ", New: "bad", Cmd: "go run . lint", Exit: 1, WantNew: "boom"}
|
|
if _, err := r.runList(context.Background(), list{Breaks: []record{b}}); err == nil {
|
|
t.Fatal("a control run that already prints the text did not stop the list")
|
|
}
|
|
}
|
|
|
|
func TestSummarizeExitsOneUnlessEveryBreakIsCaught(t *testing.T) {
|
|
var out bytes.Buffer
|
|
if got := summarize(&out, map[string]int{caught: 3}); got != 0 {
|
|
t.Errorf("all caught: exit %d, want 0", got)
|
|
}
|
|
if got := summarize(&out, map[string]int{caught: 2, missed: 1}); got != 1 {
|
|
t.Errorf("one missed: exit %d, want 1", got)
|
|
}
|
|
if !strings.Contains(out.String(), "3 breaks: 2 CAUGHT, 1 MISSED") {
|
|
t.Errorf("total line = %q", out.String())
|
|
}
|
|
}
|