diff --git a/.gremlins.yaml b/.gremlins.yaml index d373fac6..21f91de7 100644 --- a/.gremlins.yaml +++ b/.gremlins.yaml @@ -1,4 +1,5 @@ unleash: + output-statuses: lctr threshold: efficacy: 80 mutant-coverage: 90 \ No newline at end of file diff --git a/cmd/unleash.go b/cmd/unleash.go index 60cc50b4..d7eddb26 100644 --- a/cmd/unleash.go +++ b/cmd/unleash.go @@ -52,6 +52,7 @@ const ( paramBuildTags = "tags" paramCoverPackages = "coverpkg" paramDryRun = "dry-run" + paramOutputStatuses = "output-statuses" paramOutput = "output" paramIntegrationMode = "integration" paramExcludeFiles = "exclude-files" @@ -206,6 +207,7 @@ func setFlagsOnCmd(cmd *cobra.Command) error { fls := []*flags.Flag{ {Name: paramDryRun, CfgKey: configuration.UnleashDryRunKey, Shorthand: "d", DefaultV: false, Usage: "find mutations but do not executes tests"}, + {Name: paramOutputStatuses, CfgKey: configuration.UnleashOutputStatusesKey, Shorthand: "S", DefaultV: "", Usage: "print only statuses from this flag, allowed values - 'lctkvsr'"}, {Name: paramBuildTags, CfgKey: configuration.UnleashTagsKey, Shorthand: "t", DefaultV: "", Usage: "a comma-separated list of build tags"}, {Name: paramCoverPackages, CfgKey: configuration.UnleashCoverPkgKey, DefaultV: "", Usage: "a comma-separated list of package patterns"}, {Name: paramDiff, CfgKey: configuration.UnleashDiffRef, Shorthand: "D", DefaultV: "", Usage: "diff branch or commit"}, diff --git a/docs/docs/usage/commands/unleash/index.md b/docs/docs/usage/commands/unleash/index.md index 898d707c..0c2e5874 100644 --- a/docs/docs/usage/commands/unleash/index.md +++ b/docs/docs/usage/commands/unleash/index.md @@ -82,7 +82,7 @@ gremlins unleash -E "_(gen|wrap).go$" -E "^(generate|wrap)/" -E "internal/super_ ### Diff -:material-flag: `--diff` · :material-sign-direction: Default: empty +:material-flag: `--diff`/`-D` · :material-sign-direction: Default: empty Run tests only for mutants inside code changes between current state and git reference (branch or commit). The default is each mutant covered by tests. @@ -107,8 +107,6 @@ gremlins unleash --diff "origin/$GITHUB_BASE_REF" Use `actions/checkout@v4` with `fetch-depth: 0` to fetch all history. -#### Using - ### Dry run :material-flag:`--dry-run`/`-d` · :material-sign-direction: Default: false @@ -119,6 +117,55 @@ Just performs the analysis but not the mutation testing. gremlins unleash --dry-run ``` +### Statuses output + +:material-flag: `--output-statuses`/`-S` · :material-sign-direction: Default: empty - show all + +Filters stdout to print only statuses from flag. Useful to filter important findings in big project output. +Alternative to `gremlins r | grep LIVED` configured from file. + +Flag do not change json file and stats report content. + +### Examples + +#### Show only `LIVED` and `NOT COVERED` + +```shell +gremlins unleash --output-statuses "lc" +``` + +Output + +``` + LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 + NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 +``` + +#### Filter out out `SKIPPED`, `KILLED`. + +```shell +gremlins unleash --S lctv +``` + +Output + +``` + LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 + NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 + NOT VIABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 + TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3 +``` + +### Filter letters + +- `l` - LIVED +- `c` - NOT COVERED +- `t` - TIMED OUT +- `k` - KILLED +- `v` - NOT VIABLE +- `s` - SKIPPED +- `r` - RUNNABLE + ### Increment decrement :material-flag: `--increment-decrement` · :material-sign-direction: Default: `true` diff --git a/docs/docs/usage/configuration.md b/docs/docs/usage/configuration.md index 91ee032f..ea42b36a 100644 --- a/docs/docs/usage/configuration.md +++ b/docs/docs/usage/configuration.md @@ -50,6 +50,8 @@ unleash: dry-run: false tags: "" output: "" + diff: "" + output-statuses: "" workers: 0 #(1) test-cpu: 0 #(2) timeout-coefficient: 0 #(3) diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go index debb1bd3..321850fd 100644 --- a/internal/configuration/configuration.go +++ b/internal/configuration/configuration.go @@ -34,6 +34,7 @@ import ( const ( GremlinsSilentKey = "silent" UnleashDryRunKey = "unleash.dry-run" + UnleashOutputStatusesKey = "unleash.output-statuses" UnleashOutputKey = "unleash.output" UnleashTagsKey = "unleash.tags" UnleashCoverPkgKey = "unleash.coverpkg" diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c9695265..d14a5222 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -50,6 +50,7 @@ type Engine struct { codeData CodeData mutantStream chan mutator.Mutator module gomodule.GoModule + logger report.MutantLogger } // CodeData is used to check if the mutant should be executed. @@ -73,6 +74,7 @@ func New(mod gomodule.GoModule, codeData CodeData, jDealer ExecutorDealer, opts jDealer: jDealer, codeData: codeData, fs: dirFS, + logger: report.NewLogger(), } for _, opt := range opts { mut = opt(mut) @@ -224,6 +226,7 @@ func (mu *Engine) executeTests(ctx context.Context) report.Results { }() for m := range outCh { + mu.logger.Mutant(m) mutants = append(mutants, m) } diff --git a/internal/engine/executor.go b/internal/engine/executor.go index 99fc2b13..3fb5ee4d 100644 --- a/internal/engine/executor.go +++ b/internal/engine/executor.go @@ -26,14 +26,12 @@ import ( "sync" "time" + "github.com/go-gremlins/gremlins/internal/configuration" "github.com/go-gremlins/gremlins/internal/engine/workdir" "github.com/go-gremlins/gremlins/internal/engine/workerpool" + "github.com/go-gremlins/gremlins/internal/gomodule" "github.com/go-gremlins/gremlins/internal/log" "github.com/go-gremlins/gremlins/internal/mutator" - "github.com/go-gremlins/gremlins/internal/report" - - "github.com/go-gremlins/gremlins/internal/configuration" - "github.com/go-gremlins/gremlins/internal/gomodule" ) // DefaultTimeoutCoefficient is the default multiplier for the timeout length @@ -172,7 +170,6 @@ func (m *mutantExecutor) Start(w *workerpool.Worker) { if m.mutant.Status() == mutator.NotCovered || m.mutant.Status() == mutator.Skipped || m.dryRun { m.outCh <- m.mutant - report.Mutant(m.mutant) return } @@ -191,7 +188,6 @@ func (m *mutantExecutor) Start(w *workerpool.Worker) { } m.outCh <- m.mutant - report.Mutant(m.mutant) } func (m *mutantExecutor) runTests(rootDir, pkg string) mutator.Status { diff --git a/internal/report/logger.go b/internal/report/logger.go new file mode 100644 index 00000000..23dc2d1d --- /dev/null +++ b/internal/report/logger.go @@ -0,0 +1,73 @@ +package report + +import ( + "errors" + + "github.com/go-gremlins/gremlins/internal/configuration" + "github.com/go-gremlins/gremlins/internal/log" + "github.com/go-gremlins/gremlins/internal/mutator" +) + +type Filter = map[mutator.Status]struct{} + +var ErrInvalidFilter = errors.New("invalid statuses filter, only 'lctkvsr' letters allowed") + +// MutantLogger prints mutant statuses based on filter and verbosity flags. +type MutantLogger struct { + Filter +} + +func NewLogger() MutantLogger { + outputStatuses := configuration.Get[string](configuration.UnleashOutputStatusesKey) + f, err := ParseFilter(outputStatuses) + if err != nil { + log.Infof("output-statuses filter not applied: %s\n", err) + } + + return MutantLogger{ + Filter: f, + } +} + +func (l MutantLogger) Mutant(m mutator.Mutator) { + if l.Filter == nil { + Mutant(m) + + return + } + + if _, ok := l.Filter[m.Status()]; ok { + Mutant(m) + } +} + +func ParseFilter(s string) (Filter, error) { + if s == "" { + return nil, nil + } + + result := Filter{} + + for _, r := range s { + switch r { + case 'l': + result[mutator.Lived] = struct{}{} + case 'c': + result[mutator.NotCovered] = struct{}{} + case 't': + result[mutator.TimedOut] = struct{}{} + case 'k': + result[mutator.Killed] = struct{}{} + case 'v': + result[mutator.NotViable] = struct{}{} + case 's': + result[mutator.Skipped] = struct{}{} + case 'r': + result[mutator.Runnable] = struct{}{} + default: + return nil, ErrInvalidFilter + } + } + + return result, nil +} diff --git a/internal/report/logger_test.go b/internal/report/logger_test.go new file mode 100644 index 00000000..8fe632a1 --- /dev/null +++ b/internal/report/logger_test.go @@ -0,0 +1,107 @@ +package report_test + +import ( + "bytes" + "errors" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/go-gremlins/gremlins/internal/configuration" + "github.com/go-gremlins/gremlins/internal/log" + "github.com/go-gremlins/gremlins/internal/mutator" + "github.com/go-gremlins/gremlins/internal/report" +) + +func Test_parseFilter(t *testing.T) { + tests := []struct { + filter string + want report.Filter + err error + }{ + { + filter: "lc", + want: report.Filter{ + mutator.Lived: struct{}{}, + mutator.NotCovered: struct{}{}, + }, + }, + { + filter: "tkvs", + want: report.Filter{ + mutator.TimedOut: struct{}{}, + mutator.Killed: struct{}{}, + mutator.NotViable: struct{}{}, + mutator.Skipped: struct{}{}, + }, + }, + { + filter: "r", + want: report.Filter{ + mutator.Runnable: struct{}{}, + }, + }, + { + filter: "", + }, + { + filter: "lnc", + want: nil, + err: report.ErrInvalidFilter, + }, + } + for _, tt := range tests { + t.Run(tt.filter, func(t *testing.T) { + got, err := report.ParseFilter(tt.filter) + if !errors.Is(err, tt.err) { + t.Errorf("ParseFilter() error = %v, wantErr %v", err, tt.err) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseFilter() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLogger(t *testing.T) { + out := &bytes.Buffer{} + defer out.Reset() + log.Init(out, &bytes.Buffer{}) + defer log.Reset() + + m := stubMutant{status: mutator.NotCovered, mutantType: mutator.ConditionalsBoundary, position: fakePosition} + + configuration.Set(configuration.UnleashOutputStatusesKey, "lp") + logger := report.NewLogger() // prints error + + logger.Mutant(m) // prints Not covered because no filter + + m.status = mutator.Killed + + configuration.Set(configuration.UnleashOutputStatusesKey, "") + logger = report.NewLogger() + + logger.Mutant(m) // prints Killed because no filter + + configuration.Set(configuration.UnleashOutputStatusesKey, "l") + logger = report.NewLogger() + + logger.Mutant(m) // Killed filtered + + m.status = mutator.Lived + + logger.Mutant(m) // prints Lived because no filter + + got := out.String() + + want := "output-statuses filter not applied: " + report.ErrInvalidFilter.Error() + "\n" + + " NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + + " KILLED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + + " LIVED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + + if !cmp.Equal(got, want) { + t.Errorf(cmp.Diff(got, want)) + } +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go index e2bcb230..18f1ab56 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -311,6 +311,8 @@ func TestMutantLog(t *testing.T) { report.Mutant(m) m = stubMutant{status: mutator.TimedOut, mutantType: mutator.ConditionalsBoundary, position: fakePosition} report.Mutant(m) + m = stubMutant{status: mutator.Skipped, mutantType: mutator.ConditionalsBoundary, position: fakePosition} + report.Mutant(m) got := out.String() @@ -320,7 +322,8 @@ func TestMutantLog(t *testing.T) { " NOT COVERED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + " RUNNABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + " NOT VIABLE CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + - " TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + " TIMED OUT CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" + + " SKIPPED CONDITIONALS_BOUNDARY at aFolder/aFile.go:12:3\n" if !cmp.Equal(got, want) { t.Errorf(cmp.Diff(got, want))