From 6a00b7a6ef2f88129529ac0d221ea09dbef2147f Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 15 Jun 2026 23:51:58 +0300 Subject: [PATCH 01/47] draft --- Makefile | 2 +- cmd/go.mod | 10 + cmd/go.sum | 8 + cmd/testo/commands.go | 96 +++++++ cmd/testo/loader.go | 446 +++++++++++++++++++++++++++++++++ cmd/testo/main.go | 101 ++++++++ collector.go | 29 +-- examples/01_suite/main_test.go | 6 +- internal/parse/parse.go | 24 ++ 9 files changed, 694 insertions(+), 28 deletions(-) create mode 100644 cmd/go.mod create mode 100644 cmd/go.sum create mode 100644 cmd/testo/commands.go create mode 100644 cmd/testo/loader.go create mode 100644 cmd/testo/main.go diff --git a/Makefile b/Makefile index bb731fa..5bbeb30 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ coverage-html: coverage go tool cover -html coverage.out install: - go install ./cmd/testo + go install ./cmd/... update-examples-output: ./update-examples-output.sh diff --git a/cmd/go.mod b/cmd/go.mod new file mode 100644 index 0000000..b6398c7 --- /dev/null +++ b/cmd/go.mod @@ -0,0 +1,10 @@ +module github.com/ozontech/testo/cmd/testo + +go 1.26.4 + +require golang.org/x/tools v0.46.0 + +require ( + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect +) diff --git a/cmd/go.sum b/cmd/go.sum new file mode 100644 index 0000000..4e88fa1 --- /dev/null +++ b/cmd/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= diff --git a/cmd/testo/commands.go b/cmd/testo/commands.go new file mode 100644 index 0000000..f754656 --- /dev/null +++ b/cmd/testo/commands.go @@ -0,0 +1,96 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "os" +) + +type Lint struct { + Load LoadSuiteConfig +} + +func (cmd Lint) Run(patterns ...string) error { + _, err := LoadSuites(cmd.Load, patterns...) + if err == nil { + return nil + } + + if errLoad, ok := errors.AsType[*LoadError](err); ok { + w := bufio.NewWriter(os.Stdout) + + type Node struct { + File string + Line int + Kind string + Message string + } + + for _, d := range errLoad.Diagnostics { + w.WriteString(d.Format(errLoad.FSet)) + w.WriteString("\n") + } + + w.Flush() + + os.Exit(1) + } + + return err +} + +type Suites struct { + Load LoadSuiteConfig +} + +func (cmd Suites) Run(patterns ...string) error { + suites, err := LoadSuites(cmd.Load, patterns...) + if err != nil { + return err + } + + for i, s := range suites { + w := bufio.NewWriter(os.Stdout) + + fmt.Fprintln(w, "[S] "+s.Name) + + for i, t := range s.Tests { + symbol := "└" + fallback := " " + + if i != len(s.Tests)-1 { + symbol = "├" + fallback = "│" + } + + symbol += "──" + + if t.Parametrized { + fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) + + for j, p := range t.Parameters { + symbol := "└" + + if j != len(t.Parameters)-1 { + symbol = "├" + } + + symbol += "──" + + fmt.Fprintf(w, " %s %s [P] %s\n", fallback, symbol, p) + } + } else { + fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) + } + } + + if i != len(suites)-1 { + fmt.Fprintln(w) + } + + w.Flush() + } + + return nil +} diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go new file mode 100644 index 0000000..30a931a --- /dev/null +++ b/cmd/testo/loader.go @@ -0,0 +1,446 @@ +package main + +import ( + "fmt" + "go/token" + "go/types" + "os" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/tools/go/packages" +) + +type LoadError struct { + FSet *token.FileSet + Diagnostics []Diagnostic +} + +func (l *LoadError) Error() string { + msgs := make([]string, 0, len(l.Diagnostics)) + + for _, d := range l.Diagnostics { + msgs = append(msgs, d.Format(l.FSet)) + } + + return strings.Join(msgs, "\n") +} + +type LoadSuiteConfig struct { + Tags string + Testo string + Strict bool +} + +func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { + fset := token.NewFileSet() + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.LoadFiles | packages.LoadAllSyntax | packages.LoadImports, + Tests: true, + BuildFlags: []string{"-tags", cfg.Tags}, + Fset: fset, + Env: os.Environ(), + }, patterns...) + if err != nil { + return nil, err + } + + if packages.PrintErrors(pkgs) > 0 { + os.Exit(1) + } + + var suites []Suite + var diagnostics []Diagnostic + + for _, pkg := range pkgs { + if !pkg.Types.Complete() { + return nil, fmt.Errorf("package %q is not complete", pkg.Name) + } + + scope := pkg.Types.Scope() + + for _, name := range scope.Names() { + obj := scope.Lookup(name) + + suite, d, ok := cfg.asSuite(obj) + if !ok { + continue + } + + diagnostics = append(diagnostics, d...) + + suites = append(suites, suite) + } + } + + if len(diagnostics) > 0 { + return suites, &LoadError{ + FSet: fset, + Diagnostics: diagnostics, + } + } + + return suites, nil +} + +func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { + named, ok := obj.Type().(*types.Named) + if !ok { + return Suite{}, nil, false + } + + s, ok := named.Underlying().(*types.Struct) + if !ok { + return Suite{}, nil, false + } + + var t types.Type + var isSuite bool + + for f := range s.Fields() { + if !f.Embedded() { + continue + } + + if f.Name() != "Suite" { + continue + } + + suite, ok := f.Type().(*types.Named) + if !ok { + continue + } + + if suite.Obj().Pkg().Path() != c.Testo { + continue + } + + struct_ := suite.Underlying().(*types.Struct) + field := struct_.Field(0) + array := field.Type().Underlying().(*types.Array) + pointer := array.Elem().Underlying().(*types.Pointer) + + t = pointer.Elem() + isSuite = true + + break + } + + if !isSuite { + return Suite{}, nil, false + } + + suite := Suite{ + Name: named.Obj().Name(), + T: t, + } + + cases, diagnostics := collectCases(named) + if len(diagnostics) > 0 { + return suite, diagnostics, true + } + + for m := range named.Methods() { + name := m.Name() + + const prefix = "Test" + + if !strings.HasPrefix(name, prefix) { + continue + } + + if !IsTest(name, prefix) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: MalformedName(fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, name)), + }) + + continue + } + + sig := m.Signature() + + if sig.Results().Len() > 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(name + " must not return values"), + }) + + continue + } + + params := sig.Params() + + switch params.Len() { + case 1: + in := params.At(0) + + if !types.Identical(in.Type(), suite.T) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s must accept %s, got %s", name, suite.T, in.Type())), + }) + } + + suite.Tests = append(suite.Tests, SuiteTest{ + Name: name, + }) + + case 2: + first := params.At(0) + + if !types.Identical(first.Type(), suite.T) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s must accept %s, got %s", name, suite.T, first.Type())), + }) + + continue + } + + second := params.At(1) + + params, ok := second.Type().Underlying().(*types.Struct) + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s must accept struct as second parameter, got %s", name, second.Type())), + }) + + continue + } + + var invalidParams bool + + var paramNames []string + + for f := range params.Fields() { + if !f.Exported() { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s parameters must be exported, got %s", name, f.Name())), + }) + + invalidParams = true + + continue + } + + forParam, ok := cases[f.Name()] + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s requires unknown parameter %s", name, f.Name())), + }) + + invalidParams = true + + continue + } + + if !types.AssignableTo(forParam.Type, f.Type()) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s requires param %s to be of type %s, have %s", name, f.Name(), f.Type(), forParam.Type)), + }) + + invalidParams = true + + continue + } + + paramNames = append(paramNames, f.Name()) + } + + suite.Tests = append(suite.Tests, SuiteTest{ + Name: name, + Parametrized: true, + Parameters: paramNames, + }) + + if invalidParams { + continue + } + + default: + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s must accept either 1 or 2 parameters, got %d", name, params.Len())), + }) + } + } + + if c.Strict && len(diagnostics) == 0 && len(suite.Tests) == 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: obj.Pos(), + Issue: TestsMissing(fmt.Sprintf("suite %s has no tests", suite.Name)), + }) + } + + return suite, diagnostics, true +} + +type Cases map[string]Case + +type Case struct { + Name string + Type types.Type +} + +func collectCases(suite *types.Named) (Cases, []Diagnostic) { + cases := make(Cases) + + const prefix = "Cases" + + var diagnostics []Diagnostic + + for m := range suite.Methods() { + if !strings.HasPrefix(m.Name(), prefix) { + continue + } + + if !IsTest(m.Name(), prefix) { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: MalformedName(fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, m.Name())), + }) + + continue + } + + sig := m.Signature() + + if sig.Params().Len() != 0 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(m.Name() + " must not accept parameters"), + }) + + continue + } + + results := sig.Results() + + if results.Len() != 1 { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(m.Name() + " must return exactly one result"), + }) + + continue + } + + out := results.At(0).Type().Underlying() + + s, ok := out.(*types.Slice) + if !ok { + diagnostics = append(diagnostics, Diagnostic{ + Pos: m.Pos(), + Issue: InvalidSignature(fmt.Sprintf("%s must return a slice, got %s", m.Name(), out)), + }) + + continue + } + + name := strings.TrimPrefix(m.Name(), prefix) + + cases[name] = Case{ + Name: name, + Type: s.Elem(), + } + } + + return cases, diagnostics +} + +type Diagnostic struct { + Pos token.Pos + Issue Issue +} + +func (d Diagnostic) Format(set *token.FileSet) string { + file := set.File(d.Pos) + line := file.Line(d.Pos) + + return fmt.Sprintf("%s:%d: %s", file.Name(), line, d.Issue.String()) +} + +type Issue interface { + fmt.Stringer + + Kind() string + + issue() +} + +type MalformedName string + +func (mn MalformedName) Kind() string { + return "malformed name" +} + +func (mn MalformedName) String() string { + return mn.Kind() + ": " + string(mn) +} + +func (MalformedName) issue() {} + +type InvalidSignature string + +func (is InvalidSignature) Kind() string { + return "invalid signature" +} + +func (is InvalidSignature) String() string { + return is.Kind() + ": " + string(is) +} + +func (InvalidSignature) issue() {} + +type TestsMissing string + +func (tm TestsMissing) Kind() string { + return "tests missing" +} + +func (tm TestsMissing) String() string { + return tm.Kind() + ": " + string(tm) +} + +func (TestsMissing) issue() {} + +// IsTest states whether name is a valid test name (or other type, according to prefix). +// +// It checks if the next character after prefix is uppercase. +// +// TestFoo => true +// Test => true +// TestfooBar => false +func IsTest(name, prefix string) bool { + if !strings.HasPrefix(name, prefix) { + return false + } + + // "Test" is ok + if len(name) == len(prefix) { + return true + } + + r, _ := utf8.DecodeRuneInString(name[len(prefix):]) + + return !unicode.IsLower(r) +} + +type Suite struct { + Name string + Tests []SuiteTest + T types.Type +} + +type SuiteTest struct { + Name string + Parametrized bool + Parameters []string +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go new file mode 100644 index 0000000..46683da --- /dev/null +++ b/cmd/testo/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "maps" + "os" + "slices" +) + +func usage(f *flag.FlagSet) { + var buf bytes.Buffer + + fmt.Fprintf(&buf, "Usage %s:\n\n", os.Args[0]) + + for _, cmd := range slices.Sorted(maps.Keys(commands)) { + fmt.Fprintf(&buf, "%-10s %s\n", cmd, commands[cmd].Desc) + } + + f.Output().Write(buf.Bytes()) + f.PrintDefaults() +} + +func main() { + Register("lint", "run testo linter", func(f *flag.FlagSet, cmd *Lint) { + f.StringVar(&cmd.Load.Tags, "tags", "example,e2e,integration,functional,smoke", "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") + f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") + }) + + Register("suites", "show testo suites", func(f *flag.FlagSet, cmd *Suites) { + f.StringVar(&cmd.Load.Tags, "tags", "example,e2e,integration,functional,smoke", "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") + }) + + flag.Usage = func() { + usage(flag.CommandLine) + } + + if len(os.Args) < 2 { + flag.Parse() + flag.Usage() + + os.Exit(2) + } + + if err := run(os.Args[1], os.Args[2:]...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } +} + +func run(command string, args ...string) error { + r, ok := commands[command] + if !ok { + usage(flag.CommandLine) + os.Exit(2) + + return nil + } + + return r.Run(args...) +} + +type Command interface { + Run(args ...string) error +} + +var commands = make(map[string]registered) + +type registered struct { + Desc string + Run func(args ...string) error +} + +func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) { + var command C + + commands[name] = registered{ + Desc: desc, + Run: func(args ...string) error { + f := flag.NewFlagSet(name, flag.ExitOnError) + + flags(f, &command) + + old := f.Usage + f.Usage = func() { + fmt.Fprintf(f.Output(), "%s\n\n", desc) + + old() + } + + if err := f.Parse(args); err != nil { + return err + } + + return command.Run(f.Args()...) + }, + } +} diff --git a/collector.go b/collector.go index b2e24fc..c578793 100644 --- a/collector.go +++ b/collector.go @@ -6,9 +6,8 @@ import ( "slices" "strings" "testing" - "unicode" - "unicode/utf8" + "github.com/ozontech/testo/internal/parse" "github.com/ozontech/testo/internal/pragma" "github.com/ozontech/testo/internal/testnamer" "github.com/ozontech/testo/testoplugin" @@ -44,28 +43,6 @@ func (t plannedSuiteTest[Suite, T]) Annotations() []testoplugin.Option { return slices.Clone(t.inner.Options) } -// isTest states whether name is a valid test name (or other type, according to prefix). -// -// It checks if the next character after prefix is uppercase. -// -// TestFoo => true -// Test => true -// TestfooBar => false -func isTest(name, prefix string) bool { - if !strings.HasPrefix(name, prefix) { - return false - } - - // "Test" is ok - if len(name) == len(prefix) { - return true - } - - r, _ := utf8.DecodeRuneInString(name[len(prefix):]) - - return !unicode.IsLower(r) -} - func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase[Suite, T] { tb.Helper() @@ -78,7 +55,7 @@ func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase const prefix = "Cases" - if !isTest(method.Name, prefix) { + if !parse.IsTest(method.Name, prefix) { if !strings.HasPrefix(method.Name, prefix) { continue } @@ -249,7 +226,7 @@ func (tc *testsCollector[Suite, T]) Collect(tb testing.TB) suiteTests[Suite, T] const prefix = "Test" - if !isTest(method.Name, prefix) { + if !parse.IsTest(method.Name, prefix) { if !strings.HasPrefix(method.Name, prefix) { continue } diff --git a/examples/01_suite/main_test.go b/examples/01_suite/main_test.go index 938d754..71db216 100644 --- a/examples/01_suite/main_test.go +++ b/examples/01_suite/main_test.go @@ -19,7 +19,11 @@ type Suite struct { testo.Suite[T] } -func (s *Suite) TestMath(t T) { +func (*Suite) CasesAboba() []uint { + return nil +} + +func (s *Suite) TestiMath(t T) { if 2+2 != 4 { t.Errorf("expected 2 + 2 to be 4, got: %d", 2+2) } diff --git a/internal/parse/parse.go b/internal/parse/parse.go index ff1ee50..193c934 100644 --- a/internal/parse/parse.go +++ b/internal/parse/parse.go @@ -4,6 +4,8 @@ package parse import ( "strconv" "strings" + "unicode" + "unicode/utf8" ) // Bool parses string as bool treating it as false @@ -13,3 +15,25 @@ func Bool(s string) bool { return b } + +// IsTest states whether name is a valid test name (or other type, according to prefix). +// +// It checks if the next character after prefix is uppercase. +// +// TestFoo => true +// Test => true +// TestfooBar => false +func IsTest(name, prefix string) bool { + if !strings.HasPrefix(name, prefix) { + return false + } + + // "Test" is ok + if len(name) == len(prefix) { + return true + } + + r, _ := utf8.DecodeRuneInString(name[len(prefix):]) + + return !unicode.IsLower(r) +} From 4a603307af0dcd0a751735bed4e0378b064584ff Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 17 Jun 2026 00:04:16 +0300 Subject: [PATCH 02/47] remove external dependencies --- cmd/go.mod | 10 - cmd/go.sum | 8 - cmd/testo/commands.go | 11 +- cmd/testo/internal/packageslite/packages.go | 234 ++++++++++++++++++++ cmd/testo/loader.go | 153 +++++++------ cmd/testo/main.go | 7 +- examples/01_suite/main_test.go | 6 +- 7 files changed, 329 insertions(+), 100 deletions(-) delete mode 100644 cmd/go.mod delete mode 100644 cmd/go.sum create mode 100644 cmd/testo/internal/packageslite/packages.go diff --git a/cmd/go.mod b/cmd/go.mod deleted file mode 100644 index b6398c7..0000000 --- a/cmd/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module github.com/ozontech/testo/cmd/testo - -go 1.26.4 - -require golang.org/x/tools v0.46.0 - -require ( - golang.org/x/mod v0.37.0 // indirect - golang.org/x/sync v0.21.0 // indirect -) diff --git a/cmd/go.sum b/cmd/go.sum deleted file mode 100644 index 4e88fa1..0000000 --- a/cmd/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= diff --git a/cmd/testo/commands.go b/cmd/testo/commands.go index f754656..39b058d 100644 --- a/cmd/testo/commands.go +++ b/cmd/testo/commands.go @@ -17,15 +17,10 @@ func (cmd Lint) Run(patterns ...string) error { return nil } - if errLoad, ok := errors.AsType[*LoadError](err); ok { - w := bufio.NewWriter(os.Stdout) + var errLoad *LoadError - type Node struct { - File string - Line int - Kind string - Message string - } + if errors.As(err, &errLoad) { + w := bufio.NewWriter(os.Stdout) for _, d := range errLoad.Diagnostics { w.WriteString(d.Format(errLoad.FSet)) diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go new file mode 100644 index 0000000..a5d9895 --- /dev/null +++ b/cmd/testo/internal/packageslite/packages.go @@ -0,0 +1,234 @@ +// Package packageslite implement some functionality +// from the [golang.org/x/tools/go/packages]. +// +// [golang.org/x/tools/go/packages]: https://pkg.go.dev/golang.org/x/tools/go/packages +package packageslite + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +type Package struct { + Path string + Name string + Types *types.Package + Syntax []*ast.File + + depOnly bool +} + +func (p *Package) Init(fset *token.FileSet, conf *types.Config) error { + checked, err := conf.Check(p.Path, fset, p.Syntax, nil) + if err != nil { + return err + } + + p.Types = checked + + return nil +} + +type Config struct { + FSet *token.FileSet + Tags string +} + +func Load(config Config, patterns ...string) ([]*Package, error) { + listed, err := goList(config.Tags, patterns...) + if err != nil { + return nil, err + } + + pkgs := make(map[string]*Package) + + var importMap map[string]string + + conf := types.Config{ + IgnoreFuncBodies: true, + DisableUnusedImportCheck: true, + FakeImportC: true, + Importer: importerFunc(func(path string) (*types.Package, error) { + if path == "unsafe" { + return types.Unsafe, nil + } + + if importMap != nil { + // Taken from https://github.com/tinygo-org/tinygo/pull/1588/files + if to, ok := importMap[path]; ok && !strings.HasSuffix(to, ".test]") { + path = to + } + } + + pkg, ok := pkgs[path] + if !ok { + return nil, errors.New("package not found") + } + + return pkg.Types, nil + }), + } + + for _, l := range listed { + importMap = l.ImportMap + + pkg, err := l.Package(config.FSet) + if err != nil { + return nil, err + } + + err = pkg.Init(config.FSet, &conf) + if err != nil { + return nil, err + } + + pkgs[pkg.Path] = &pkg + } + + var direct []*Package + + for _, pkg := range pkgs { + if pkg.depOnly { + continue + } + + direct = append(direct, pkg) + } + + return direct, nil +} + +var _ types.Importer = (*importerFunc)(nil) + +type importerFunc func(path string) (*types.Package, error) + +func (i importerFunc) Import(path string) (*types.Package, error) { + return i(path) +} + +type goPackage struct { + Dir string + ImportPath string + Name string + DepOnly bool + GoFiles []string + CGoFiles []string + Imports []string + ImportMap map[string]string + Incomplete bool + Error *struct { + Err string + } + + order int +} + +func (gp goPackage) Package(fset *token.FileSet) (Package, error) { + names := slices.Concat( + gp.GoFiles, + gp.CGoFiles, + ) + + files := make([]*ast.File, 0, len(names)) + + for _, n := range names { + file, err := parser.ParseFile( + fset, + filepath.Join(gp.Dir, n), + nil, + parser.ParseComments, + ) + if err != nil { + return Package{}, err + } + + files = append(files, file) + } + + return Package{ + Path: gp.ImportPath, + Name: gp.Name, + Types: types.NewPackage(gp.ImportPath, gp.Name), + Syntax: files, + depOnly: gp.DepOnly, + }, nil +} + +func goList(tags string, patterns ...string) ([]goPackage, error) { + args := []string{ + "list", + "-e", + "-deps", + "-test", + "-export", + "-buildvcs=false", + "-pgo=off", + "-tags", + tags, + "-json=Dir,ImportPath,Name,DepOnly,GoFiles,CGoFiles,Imports,ImportMap,Incomplete,Error", + "--", + } + + args = append(args, patterns...) + + //nolint:gosec // variable only affects patterns, safe to use + cmd := exec.Command("go", args...) + + cmd.Env = os.Environ() + + out, err := cmd.Output() + if err != nil { + var errExit *exec.ExitError + + if errors.As(err, &errExit) { + return nil, fmt.Errorf("go list: %w", err) + } + + return nil, err + } + + var packages []goPackage + + dec := json.NewDecoder(bytes.NewReader(out)) + + var i int + + for dec.More() { + i++ + + var pkg goPackage + + err = dec.Decode(&pkg) + if err != nil { + return nil, err + } + + if strings.HasSuffix(pkg.ImportPath, ".test") { + continue + } + + if len(pkg.Imports) > 0 { + pkg.order = i + } + + packages = append(packages, pkg) + } + + slices.SortStableFunc(packages, func(a, b goPackage) int { + return cmp.Compare(a.order, b.order) + }) + + return packages, nil +} diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go index 30a931a..bb201f2 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/loader.go @@ -4,12 +4,10 @@ import ( "fmt" "go/token" "go/types" - "os" "strings" - "unicode" - "unicode/utf8" - "golang.org/x/tools/go/packages" + "github.com/ozontech/testo/cmd/testo/internal/packageslite" + "github.com/ozontech/testo/internal/parse" ) type LoadError struct { @@ -36,23 +34,22 @@ type LoadSuiteConfig struct { func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { fset := token.NewFileSet() - pkgs, err := packages.Load(&packages.Config{ - Mode: packages.LoadFiles | packages.LoadAllSyntax | packages.LoadImports, - Tests: true, - BuildFlags: []string{"-tags", cfg.Tags}, - Fset: fset, - Env: os.Environ(), + pkgs, err := packageslite.Load(packageslite.Config{ + FSet: fset, + Tags: cfg.Tags, }, patterns...) if err != nil { return nil, err } - if packages.PrintErrors(pkgs) > 0 { - os.Exit(1) - } + // if packages.PrintErrors(pkgs) > 0 { + // os.Exit(1) + // } - var suites []Suite - var diagnostics []Diagnostic + var ( + suites []Suite + diagnostics []Diagnostic + ) for _, pkg := range pkgs { if !pkg.Types.Complete() { @@ -96,8 +93,10 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { return Suite{}, nil, false } - var t types.Type - var isSuite bool + var ( + t types.Type + isSuite bool + ) for f := range s.Fields() { if !f.Embedded() { @@ -117,8 +116,8 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { continue } - struct_ := suite.Underlying().(*types.Struct) - field := struct_.Field(0) + aStruct := suite.Underlying().(*types.Struct) + field := aStruct.Field(0) array := field.Type().Underlying().(*types.Array) pointer := array.Elem().Underlying().(*types.Pointer) @@ -134,7 +133,7 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { suite := Suite{ Name: named.Obj().Name(), - T: t, + T: T{Type: t}, } cases, diagnostics := collectCases(named) @@ -151,10 +150,12 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { continue } - if !IsTest(name, prefix) { + if !parse.IsTest(name, prefix) { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: MalformedName(fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, name)), + Pos: m.Pos(), + Issue: MalformedName( + fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, name), + ), }) continue @@ -177,10 +178,12 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { case 1: in := params.At(0) - if !types.Identical(in.Type(), suite.T) { + if !types.Identical(in.Type(), suite.T.Type) { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s must accept %s, got %s", name, suite.T, in.Type())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s must accept %s, got %s", name, suite.T, in.Type()), + ), }) } @@ -191,10 +194,12 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { case 2: first := params.At(0) - if !types.Identical(first.Type(), suite.T) { + if !types.Identical(first.Type(), suite.T.Type) { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s must accept %s, got %s", name, suite.T, first.Type())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s must accept %s, got %s", name, suite.T, first.Type()), + ), }) continue @@ -205,8 +210,14 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { params, ok := second.Type().Underlying().(*types.Struct) if !ok { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s must accept struct as second parameter, got %s", name, second.Type())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept struct as second parameter, got %s", + name, + second.Type(), + ), + ), }) continue @@ -219,8 +230,10 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { for f := range params.Fields() { if !f.Exported() { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s parameters must be exported, got %s", name, f.Name())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s parameters must be exported, got %s", name, f.Name()), + ), }) invalidParams = true @@ -231,8 +244,10 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { forParam, ok := cases[f.Name()] if !ok { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s requires unknown parameter %s", name, f.Name())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s requires unknown parameter %s", name, f.Name()), + ), }) invalidParams = true @@ -242,8 +257,16 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { if !types.AssignableTo(forParam.Type, f.Type()) { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s requires param %s to be of type %s, have %s", name, f.Name(), f.Type(), forParam.Type)), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s requires param %s to be of type %s, have %s", + name, + f.Name(), + f.Type(), + forParam.Type, + ), + ), }) invalidParams = true @@ -266,8 +289,14 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { default: diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s must accept either 1 or 2 parameters, got %d", name, params.Len())), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf( + "%s must accept either 1 or 2 parameters, got %d", + name, + params.Len(), + ), + ), }) } } @@ -301,10 +330,16 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { continue } - if !IsTest(m.Name(), prefix) { + if !parse.IsTest(m.Name(), prefix) { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: MalformedName(fmt.Sprintf("first letter after %q in %q must not be lowercase", prefix, m.Name())), + Pos: m.Pos(), + Issue: MalformedName( + fmt.Sprintf( + "first letter after %q in %q must not be lowercase", + prefix, + m.Name(), + ), + ), }) continue @@ -337,8 +372,10 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { s, ok := out.(*types.Slice) if !ok { diagnostics = append(diagnostics, Diagnostic{ - Pos: m.Pos(), - Issue: InvalidSignature(fmt.Sprintf("%s must return a slice, got %s", m.Name(), out)), + Pos: m.Pos(), + Issue: InvalidSignature( + fmt.Sprintf("%s must return a slice, got %s", m.Name(), out), + ), }) continue @@ -411,32 +448,14 @@ func (tm TestsMissing) String() string { func (TestsMissing) issue() {} -// IsTest states whether name is a valid test name (or other type, according to prefix). -// -// It checks if the next character after prefix is uppercase. -// -// TestFoo => true -// Test => true -// TestfooBar => false -func IsTest(name, prefix string) bool { - if !strings.HasPrefix(name, prefix) { - return false - } - - // "Test" is ok - if len(name) == len(prefix) { - return true - } - - r, _ := utf8.DecodeRuneInString(name[len(prefix):]) - - return !unicode.IsLower(r) -} - type Suite struct { Name string Tests []SuiteTest - T types.Type + T T +} + +type T struct { + Type types.Type } type SuiteTest struct { diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 46683da..c909858 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -23,14 +23,16 @@ func usage(f *flag.FlagSet) { } func main() { + const defaultTags = "example,e2e,integration,functional,smoke" + Register("lint", "run testo linter", func(f *flag.FlagSet, cmd *Lint) { - f.StringVar(&cmd.Load.Tags, "tags", "example,e2e,integration,functional,smoke", "build tags separated by comma") + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") }) Register("suites", "show testo suites", func(f *flag.FlagSet, cmd *Suites) { - f.StringVar(&cmd.Load.Tags, "tags", "example,e2e,integration,functional,smoke", "build tags separated by comma") + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") }) @@ -85,6 +87,7 @@ func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) flags(f, &command) old := f.Usage + f.Usage = func() { fmt.Fprintf(f.Output(), "%s\n\n", desc) diff --git a/examples/01_suite/main_test.go b/examples/01_suite/main_test.go index 71db216..938d754 100644 --- a/examples/01_suite/main_test.go +++ b/examples/01_suite/main_test.go @@ -19,11 +19,7 @@ type Suite struct { testo.Suite[T] } -func (*Suite) CasesAboba() []uint { - return nil -} - -func (s *Suite) TestiMath(t T) { +func (s *Suite) TestMath(t T) { if 2+2 != 4 { t.Errorf("expected 2 + 2 to be 4, got: %d", 2+2) } From 819be66e18c2d33a3d32fcc0ef8a41f2641c569b Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 17 Jun 2026 11:30:43 +0300 Subject: [PATCH 03/47] simplify loader --- cmd/testo/loader.go | 64 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go index bb201f2..f0c6351 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/loader.go @@ -94,46 +94,24 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { } var ( - t types.Type - isSuite bool + t T + hasT bool ) for f := range s.Fields() { - if !f.Embedded() { - continue - } - - if f.Name() != "Suite" { - continue - } - - suite, ok := f.Type().(*types.Named) - if !ok { - continue - } - - if suite.Obj().Pkg().Path() != c.Testo { - continue + t, hasT = c.asT(f) + if hasT { + break } - - aStruct := suite.Underlying().(*types.Struct) - field := aStruct.Field(0) - array := field.Type().Underlying().(*types.Array) - pointer := array.Elem().Underlying().(*types.Pointer) - - t = pointer.Elem() - isSuite = true - - break } - if !isSuite { + if !hasT { return Suite{}, nil, false } suite := Suite{ Name: named.Obj().Name(), - T: T{Type: t}, + T: t, } cases, diagnostics := collectCases(named) @@ -311,6 +289,34 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { return suite, diagnostics, true } +func (c LoadSuiteConfig) asT(f *types.Var) (T, bool) { + if !f.Embedded() { + return T{}, false + } + + if f.Name() != "Suite" { + return T{}, false + } + + suite, ok := f.Type().(*types.Named) + if !ok { + return T{}, false + } + + if suite.Obj().Pkg().Path() != c.Testo { + return T{}, false + } + + aStruct := suite.Underlying().(*types.Struct) + field := aStruct.Field(0) + array := field.Type().Underlying().(*types.Array) + pointer := array.Elem().Underlying().(*types.Pointer) + + elem := pointer.Elem() + + return T{Type: elem}, true +} + type Cases map[string]Case type Case struct { From 5c47fc79f1c5bbf600d9ca3a70d80ad5d88e5a4a Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 17 Jun 2026 11:32:53 +0300 Subject: [PATCH 04/47] remove go.sum --- go.sum | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 go.sum diff --git a/go.sum b/go.sum deleted file mode 100644 index e69de29..0000000 From 39b9164fc5069ba7a2d3dea50e554b0cbeb7ac51 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 18:52:44 +0300 Subject: [PATCH 05/47] add private cases fields linter --- cmd/testo/internal/packageslite/packages.go | 3 +- cmd/testo/loader.go | 74 ++++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index a5d9895..9bf5336 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -177,7 +177,8 @@ func goList(tags string, patterns ...string) ([]goPackage, error) { "-pgo=off", "-tags", tags, - "-json=Dir,ImportPath,Name,DepOnly,GoFiles,CGoFiles,Imports,ImportMap,Incomplete,Error", + // "-json=Dir,ImportPath,Name,DepOnly,GoFiles,CGoFiles,Imports,ImportMap,Incomplete,Error", + "-json", "--", } diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go index f0c6351..2d86155 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/loader.go @@ -114,8 +114,8 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { T: t, } - cases, diagnostics := collectCases(named) - if len(diagnostics) > 0 { + cases, diagnostics, fatal := c.collectCases(named) + if fatal { return suite, diagnostics, true } @@ -324,13 +324,13 @@ type Case struct { Type types.Type } -func collectCases(suite *types.Named) (Cases, []Diagnostic) { - cases := make(Cases) +func (c LoadSuiteConfig) collectCases( + suite *types.Named, +) (cases Cases, diagnostics []Diagnostic, fatal bool) { + cases = make(Cases) const prefix = "Cases" - var diagnostics []Diagnostic - for m := range suite.Methods() { if !strings.HasPrefix(m.Name(), prefix) { continue @@ -348,6 +348,8 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { ), }) + fatal = true + continue } @@ -359,6 +361,8 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { Issue: InvalidSignature(m.Name() + " must not accept parameters"), }) + fatal = true + continue } @@ -370,6 +374,8 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { Issue: InvalidSignature(m.Name() + " must return exactly one result"), }) + fatal = true + continue } @@ -384,18 +390,58 @@ func collectCases(suite *types.Named) (Cases, []Diagnostic) { ), }) + fatal = true + continue } name := strings.TrimPrefix(m.Name(), prefix) + elem := s.Elem() + + if private, numFields := findPrivateFields(elem); len(private) > 0 { + if len(private) == numFields { + diagnostics = append(diagnostics, Diagnostic{ + Pos: private[0].Pos(), + Issue: PrivateField(fmt.Sprintf( + "type returned by %s%s contains only private fields", + prefix, name, + )), + }) + } else if c.Strict { + diagnostics = append(diagnostics, Diagnostic{ + Pos: private[0].Pos(), + Issue: PrivateField(fmt.Sprintf( + "type returned by %s%s contains private field %q", + prefix, name, private[0].Name(), + )), + }) + } + } cases[name] = Case{ Name: name, - Type: s.Elem(), + Type: elem, } } - return cases, diagnostics + return cases, diagnostics, fatal +} + +func findPrivateFields(t types.Type) ([]*types.Var, int) { + s, ok := t.Underlying().(*types.Struct) + if !ok { + return nil, 0 + } + + private := make([]*types.Var, 0, s.NumFields()) + + for f := range s.Fields() { + if !f.Exported() { + private = append(private, f) + } + } + + return private, s.NumFields() } type Diagnostic struct { @@ -418,6 +464,18 @@ type Issue interface { issue() } +type PrivateField string + +func (pf PrivateField) Kind() string { + return "private field" +} + +func (pf PrivateField) String() string { + return pf.Kind() + ": " + string(pf) +} + +func (PrivateField) issue() {} + type MalformedName string func (mn MalformedName) Kind() string { From f566211fee3a8bcc3213578ea4169df1e45c31b4 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 19:39:43 +0300 Subject: [PATCH 06/47] add json flag --- cmd/testo/commands.go | 9 +++++++-- cmd/testo/loader.go | 47 +++++++++++++++++++++++++++++++++++++++---- cmd/testo/main.go | 1 + 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/cmd/testo/commands.go b/cmd/testo/commands.go index 39b058d..106ce06 100644 --- a/cmd/testo/commands.go +++ b/cmd/testo/commands.go @@ -9,6 +9,7 @@ import ( type Lint struct { Load LoadSuiteConfig + JSON bool } func (cmd Lint) Run(patterns ...string) error { @@ -23,8 +24,12 @@ func (cmd Lint) Run(patterns ...string) error { w := bufio.NewWriter(os.Stdout) for _, d := range errLoad.Diagnostics { - w.WriteString(d.Format(errLoad.FSet)) - w.WriteString("\n") + if cmd.JSON { + d.FormatJSON(w, errLoad.FSet) + } else { + w.WriteString(d.Format(errLoad.FSet)) + w.WriteString("\n") + } } w.Flush() diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go index 2d86155..0a67c70 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/loader.go @@ -1,9 +1,11 @@ package main import ( + "encoding/json" "fmt" "go/token" "go/types" + "io" "strings" "github.com/ozontech/testo/cmd/testo/internal/packageslite" @@ -449,6 +451,26 @@ type Diagnostic struct { Issue Issue } +func (d Diagnostic) FormatJSON(w io.Writer, set *token.FileSet) { + type Entry struct { + File string `json:"file"` + Line int `json:"line"` + Kind string `json:"kind"` + Message string `json:"message"` + } + + file := set.File(d.Pos) + + entry := Entry{ + File: file.Name(), + Line: file.Line(d.Pos), + Kind: d.Issue.Kind(), + Message: d.Issue.Message(), + } + + _ = json.NewEncoder(w).Encode(entry) +} + func (d Diagnostic) Format(set *token.FileSet) string { file := set.File(d.Pos) line := file.Line(d.Pos) @@ -460,6 +482,7 @@ type Issue interface { fmt.Stringer Kind() string + Message() string issue() } @@ -470,8 +493,12 @@ func (pf PrivateField) Kind() string { return "private field" } +func (pf PrivateField) Message() string { + return string(pf) +} + func (pf PrivateField) String() string { - return pf.Kind() + ": " + string(pf) + return pf.Kind() + ": " + pf.Message() } func (PrivateField) issue() {} @@ -482,8 +509,12 @@ func (mn MalformedName) Kind() string { return "malformed name" } +func (mn MalformedName) Message() string { + return string(mn) +} + func (mn MalformedName) String() string { - return mn.Kind() + ": " + string(mn) + return mn.Kind() + ": " + mn.Message() } func (MalformedName) issue() {} @@ -494,8 +525,12 @@ func (is InvalidSignature) Kind() string { return "invalid signature" } +func (is InvalidSignature) Message() string { + return string(is) +} + func (is InvalidSignature) String() string { - return is.Kind() + ": " + string(is) + return is.Kind() + ": " + is.Message() } func (InvalidSignature) issue() {} @@ -506,8 +541,12 @@ func (tm TestsMissing) Kind() string { return "tests missing" } +func (tm TestsMissing) Message() string { + return string(tm) +} + func (tm TestsMissing) String() string { - return tm.Kind() + ": " + string(tm) + return tm.Kind() + ": " + tm.Message() } func (TestsMissing) issue() {} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index c909858..5625a74 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -29,6 +29,7 @@ func main() { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") + f.BoolVar(&cmd.JSON, "json", false, "output json") }) Register("suites", "show testo suites", func(f *flag.FlagSet, cmd *Suites) { From b026227d9ca71e79a82ab2604d504d9230ac8c1e Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 19:59:57 +0300 Subject: [PATCH 07/47] add logo --- cmd/testo/logo.txt | 15 +++++++++++++++ cmd/testo/main.go | 27 ++++++++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 cmd/testo/logo.txt diff --git a/cmd/testo/logo.txt b/cmd/testo/logo.txt new file mode 100644 index 0000000..94edc3a --- /dev/null +++ b/cmd/testo/logo.txt @@ -0,0 +1,15 @@ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣷⡄ +⠀⠀⠀⠀⢀⣴⣿⣿⣷⣄⠀⣿⣿⣿⣿⣿⣿⣿⠇⣠⣾⣿⣿⣿⣦ +⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣷⡸⣿⣿⣿⣿⣿⣿⣼⣿⣿⣿⣿⣿⣿⡆ +⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ +⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇ +⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿ +⠀⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⠁ +⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⠙⠿⠿⠿⠿⠹⣿⣿⣿⢏⣿⣿⠃ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⣴⣶⣶⣶⣤⣤⣤⣁⠘⠛⠁ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣉⣉⣀⣀⣈⣉⣉⠙⠛⠿⣿ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⣿⣿⣿⣿⣿⣿⣷⡶⠄ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠁ diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 5625a74..0334150 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "flag" "fmt" "maps" @@ -9,13 +10,19 @@ import ( "slices" ) +//go:embed logo.txt +var logo string + func usage(f *flag.FlagSet) { var buf bytes.Buffer - fmt.Fprintf(&buf, "Usage %s:\n\n", os.Args[0]) + fmt.Fprint(&buf, logo) + fmt.Fprintln(&buf, "Usage:") + fmt.Fprintf(&buf, " %s [command]\n\n", os.Args[0]) + fmt.Fprintln(&buf, "Available Commands:") for _, cmd := range slices.Sorted(maps.Keys(commands)) { - fmt.Fprintf(&buf, "%-10s %s\n", cmd, commands[cmd].Desc) + fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Desc) } f.Output().Write(buf.Bytes()) @@ -24,17 +31,18 @@ func usage(f *flag.FlagSet) { func main() { const defaultTags = "example,e2e,integration,functional,smoke" + const defaultTesto = "github.com/ozontech/testo" - Register("lint", "run testo linter", func(f *flag.FlagSet, cmd *Lint) { + Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&cmd.JSON, "json", false, "output json") }) - Register("suites", "show testo suites", func(f *flag.FlagSet, cmd *Suites) { + Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", "github.com/ozontech/testo", "testo package path") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") }) flag.Usage = func() { @@ -87,12 +95,13 @@ func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) flags(f, &command) - old := f.Usage - f.Usage = func() { fmt.Fprintf(f.Output(), "%s\n\n", desc) + fmt.Fprintln(f.Output(), "Usage:") + fmt.Fprintf(f.Output(), " %s %s [flags]\n\n", os.Args[0], name) + fmt.Fprintln(f.Output(), "Flags:") - old() + f.PrintDefaults() } if err := f.Parse(args); err != nil { From a00e7ebd40e778e7193984f837616060cc6f5dd5 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 20:11:31 +0300 Subject: [PATCH 08/47] add version command --- cmd/testo/commands.go | 16 ++++++++++++++++ cmd/testo/main.go | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/cmd/testo/commands.go b/cmd/testo/commands.go index 106ce06..30b01e5 100644 --- a/cmd/testo/commands.go +++ b/cmd/testo/commands.go @@ -5,8 +5,24 @@ import ( "errors" "fmt" "os" + "runtime/debug" ) +type Version struct{} + +func (cmd Version) Run(...string) error { + version := "unknown" + + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + + fmt.Println("testo version " + version) + + return nil +} + type Lint struct { Load LoadSuiteConfig JSON bool diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 0334150..5fb741c 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -45,6 +45,8 @@ func main() { f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") }) + Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) + flag.Usage = func() { usage(flag.CommandLine) } @@ -65,6 +67,8 @@ func main() { func run(command string, args ...string) error { r, ok := commands[command] if !ok { + fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand %q\n\n", command) + usage(flag.CommandLine) os.Exit(2) From 3fe1fe3f3a9cdb4d876873ab9311cc14fbab3e7e Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 20:18:28 +0300 Subject: [PATCH 09/47] better cli ux --- cmd/testo/main.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 5fb741c..6f0a9cf 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -102,6 +102,17 @@ func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) f.Usage = func() { fmt.Fprintf(f.Output(), "%s\n\n", desc) fmt.Fprintln(f.Output(), "Usage:") + + var hasFlags bool + + f.VisitAll(func(*flag.Flag) { hasFlags = true }) + + if !hasFlags { + fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) + + return + } + fmt.Fprintf(f.Output(), " %s %s [flags]\n\n", os.Args[0], name) fmt.Fprintln(f.Output(), "Flags:") From 8fdb5ec580c06932450c80b3ba4e4403628fc039 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 21:21:09 +0300 Subject: [PATCH 10/47] improve lint output --- cmd/testo/loader.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/testo/loader.go b/cmd/testo/loader.go index 0a67c70..8acaec0 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/loader.go @@ -162,7 +162,12 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { diagnostics = append(diagnostics, Diagnostic{ Pos: m.Pos(), Issue: InvalidSignature( - fmt.Sprintf("%s must accept %s, got %s", name, suite.T, in.Type()), + fmt.Sprintf( + "%s must accept %s, got %s", + name, + formatType(suite.T.Type), + formatType(in.Type()), + ), ), }) } @@ -178,7 +183,12 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { diagnostics = append(diagnostics, Diagnostic{ Pos: m.Pos(), Issue: InvalidSignature( - fmt.Sprintf("%s must accept %s, got %s", name, suite.T, first.Type()), + fmt.Sprintf( + "%s must accept %s, got %s", + name, + formatType(suite.T.Type), + formatType(first.Type()), + ), ), }) @@ -566,3 +576,23 @@ type SuiteTest struct { Parametrized bool Parameters []string } + +func formatType(t types.Type) string { + switch t := t.(type) { + case *types.Named: + return formatNamedType(t) + + case *types.Pointer: + return "*" + formatType(t.Elem()) + + default: + return t.String() + } +} + +func formatNamedType(t *types.Named) string { + pkg := t.Obj().Pkg().Name() + name := t.Obj().Name() + + return pkg + "." + name +} From 3d85ec55b76d8c1cdadb8d92acea0186b01de2f5 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 18 Jun 2026 22:30:56 +0300 Subject: [PATCH 11/47] move cmd packages to internal --- cmd/testo/internal/cli/cli.go | 112 +++++++++++++++++++ cmd/testo/{ => internal/cli}/commands.go | 37 +++++-- cmd/testo/{ => internal/cli}/logo.txt | 0 cmd/testo/{ => internal/loader}/loader.go | 10 +- cmd/testo/main.go | 126 +--------------------- 5 files changed, 153 insertions(+), 132 deletions(-) create mode 100644 cmd/testo/internal/cli/cli.go rename cmd/testo/{ => internal/cli}/commands.go (55%) rename cmd/testo/{ => internal/cli}/logo.txt (100%) rename cmd/testo/{ => internal/loader}/loader.go (98%) diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go new file mode 100644 index 0000000..8b401ce --- /dev/null +++ b/cmd/testo/internal/cli/cli.go @@ -0,0 +1,112 @@ +package cli + +import ( + "bytes" + _ "embed" + "flag" + "fmt" + "maps" + "os" + "slices" +) + +//go:embed logo.txt +var logo string + +func usage(f *flag.FlagSet) { + var buf bytes.Buffer + + fmt.Fprint(&buf, logo) + fmt.Fprintln(&buf, "Usage:") + fmt.Fprintf(&buf, " %s [command]\n\n", os.Args[0]) + fmt.Fprintln(&buf, "Available Commands:") + + for _, cmd := range slices.Sorted(maps.Keys(commands)) { + fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Desc) + } + + f.Output().Write(buf.Bytes()) + f.PrintDefaults() +} + +func Run() { + flag.Usage = func() { + usage(flag.CommandLine) + } + + if len(os.Args) < 2 { + flag.Parse() + flag.Usage() + + os.Exit(2) + } + + if err := run(os.Args[1], os.Args[2:]...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } +} + +func run(command string, args ...string) error { + r, ok := commands[command] + if !ok { + fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand %q\n\n", command) + + usage(flag.CommandLine) + os.Exit(2) + + return nil + } + + return r.Run(args...) +} + +type Command interface { + Run(args ...string) error +} + +var commands = make(map[string]registered) + +type registered struct { + Desc string + Run func(args ...string) error +} + +func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) { + var command C + + commands[name] = registered{ + Desc: desc, + Run: func(args ...string) error { + f := flag.NewFlagSet(name, flag.ExitOnError) + + flags(f, &command) + + f.Usage = func() { + fmt.Fprintf(f.Output(), "%s\n\n", desc) + fmt.Fprintln(f.Output(), "Usage:") + + var hasFlags bool + + f.VisitAll(func(*flag.Flag) { hasFlags = true }) + + if !hasFlags { + fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) + + return + } + + fmt.Fprintf(f.Output(), " %s %s [flags]\n\n", os.Args[0], name) + fmt.Fprintln(f.Output(), "Flags:") + + f.PrintDefaults() + } + + if err := f.Parse(args); err != nil { + return err + } + + return command.Run(f.Args()...) + }, + } +} diff --git a/cmd/testo/commands.go b/cmd/testo/internal/cli/commands.go similarity index 55% rename from cmd/testo/commands.go rename to cmd/testo/internal/cli/commands.go index 30b01e5..9b1455f 100644 --- a/cmd/testo/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -1,13 +1,36 @@ -package main +package cli import ( "bufio" "errors" + "flag" "fmt" "os" + "runtime" "runtime/debug" + + "github.com/ozontech/testo/cmd/testo/internal/loader" ) +func init() { + const defaultTags = "example,e2e,integration,functional,smoke" + const defaultTesto = "github.com/ozontech/testo" + + Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") + f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") + f.BoolVar(&cmd.JSON, "json", false, "output json") + }) + + Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") + }) + + Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) +} + type Version struct{} func (cmd Version) Run(...string) error { @@ -18,23 +41,23 @@ func (cmd Version) Run(...string) error { version = info.Main.Version } - fmt.Println("testo version " + version) + fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) return nil } type Lint struct { - Load LoadSuiteConfig + Load loader.LoadSuiteConfig JSON bool } func (cmd Lint) Run(patterns ...string) error { - _, err := LoadSuites(cmd.Load, patterns...) + _, err := loader.LoadSuites(cmd.Load, patterns...) if err == nil { return nil } - var errLoad *LoadError + var errLoad *loader.LoadError if errors.As(err, &errLoad) { w := bufio.NewWriter(os.Stdout) @@ -57,11 +80,11 @@ func (cmd Lint) Run(patterns ...string) error { } type Suites struct { - Load LoadSuiteConfig + Load loader.LoadSuiteConfig } func (cmd Suites) Run(patterns ...string) error { - suites, err := LoadSuites(cmd.Load, patterns...) + suites, err := loader.LoadSuites(cmd.Load, patterns...) if err != nil { return err } diff --git a/cmd/testo/logo.txt b/cmd/testo/internal/cli/logo.txt similarity index 100% rename from cmd/testo/logo.txt rename to cmd/testo/internal/cli/logo.txt diff --git a/cmd/testo/loader.go b/cmd/testo/internal/loader/loader.go similarity index 98% rename from cmd/testo/loader.go rename to cmd/testo/internal/loader/loader.go index 8acaec0..855403c 100644 --- a/cmd/testo/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -1,4 +1,4 @@ -package main +package loader import ( "encoding/json" @@ -6,6 +6,7 @@ import ( "go/token" "go/types" "io" + "slices" "strings" "github.com/ozontech/testo/cmd/testo/internal/packageslite" @@ -74,6 +75,13 @@ func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { } } + slices.SortFunc(diagnostics, func(a, b Diagnostic) int { + return strings.Compare( + fset.File(a.Pos).Name(), + fset.File(b.Pos).Name(), + ) + }) + if len(diagnostics) > 0 { return suites, &LoadError{ FSet: fset, diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 6f0a9cf..775f31c 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -1,129 +1,7 @@ package main -import ( - "bytes" - _ "embed" - "flag" - "fmt" - "maps" - "os" - "slices" -) - -//go:embed logo.txt -var logo string - -func usage(f *flag.FlagSet) { - var buf bytes.Buffer - - fmt.Fprint(&buf, logo) - fmt.Fprintln(&buf, "Usage:") - fmt.Fprintf(&buf, " %s [command]\n\n", os.Args[0]) - fmt.Fprintln(&buf, "Available Commands:") - - for _, cmd := range slices.Sorted(maps.Keys(commands)) { - fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Desc) - } - - f.Output().Write(buf.Bytes()) - f.PrintDefaults() -} +import "github.com/ozontech/testo/cmd/testo/internal/cli" func main() { - const defaultTags = "example,e2e,integration,functional,smoke" - const defaultTesto = "github.com/ozontech/testo" - - Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") - f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") - f.BoolVar(&cmd.JSON, "json", false, "output json") - }) - - Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") - }) - - Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) - - flag.Usage = func() { - usage(flag.CommandLine) - } - - if len(os.Args) < 2 { - flag.Parse() - flag.Usage() - - os.Exit(2) - } - - if err := run(os.Args[1], os.Args[2:]...); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) - } -} - -func run(command string, args ...string) error { - r, ok := commands[command] - if !ok { - fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand %q\n\n", command) - - usage(flag.CommandLine) - os.Exit(2) - - return nil - } - - return r.Run(args...) -} - -type Command interface { - Run(args ...string) error -} - -var commands = make(map[string]registered) - -type registered struct { - Desc string - Run func(args ...string) error -} - -func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) { - var command C - - commands[name] = registered{ - Desc: desc, - Run: func(args ...string) error { - f := flag.NewFlagSet(name, flag.ExitOnError) - - flags(f, &command) - - f.Usage = func() { - fmt.Fprintf(f.Output(), "%s\n\n", desc) - fmt.Fprintln(f.Output(), "Usage:") - - var hasFlags bool - - f.VisitAll(func(*flag.Flag) { hasFlags = true }) - - if !hasFlags { - fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) - - return - } - - fmt.Fprintf(f.Output(), " %s %s [flags]\n\n", os.Args[0], name) - fmt.Fprintln(f.Output(), "Flags:") - - f.PrintDefaults() - } - - if err := f.Parse(args); err != nil { - return err - } - - return command.Run(f.Args()...) - }, - } + cli.Run() } From 38c37336b4d003fb44628f1ec0c72cf096ead082 Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 19 Jun 2026 10:15:29 +0300 Subject: [PATCH 12/47] add bench --- .../internal/packageslite/packages_test.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 cmd/testo/internal/packageslite/packages_test.go diff --git a/cmd/testo/internal/packageslite/packages_test.go b/cmd/testo/internal/packageslite/packages_test.go new file mode 100644 index 0000000..65ea0d5 --- /dev/null +++ b/cmd/testo/internal/packageslite/packages_test.go @@ -0,0 +1,22 @@ +package packageslite + +import ( + "go/token" + "testing" +) + +func BenchmarkLoad(b *testing.B) { + b.Chdir("/Users/vzbarashchenko/Code/ozon/mass-crm-api") + + conf := Config{ + FSet: token.NewFileSet(), + Tags: "e2e,smoke,functional,integration", + } + + for b.Loop() { + _, err := Load(conf, "./...") + if err != nil { + b.Fatal(err) + } + } +} From e44bc6ff78850df55ecf10fe42957d51f4178ad9 Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 19 Jun 2026 10:29:39 +0300 Subject: [PATCH 13/47] skip object resolution --- cmd/testo/internal/packageslite/packages.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index 9bf5336..f30e0ee 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -148,7 +148,7 @@ func (gp goPackage) Package(fset *token.FileSet) (Package, error) { fset, filepath.Join(gp.Dir, n), nil, - parser.ParseComments, + parser.ParseComments|parser.SkipObjectResolution, ) if err != nil { return Package{}, err From 8d628a530b8b01d2a08e276e9bf237320528ed1c Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 19 Jun 2026 10:45:43 +0300 Subject: [PATCH 14/47] skip parsing for export data --- cmd/testo/internal/packageslite/packages.go | 61 +++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index f30e0ee..7f60d51 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -11,9 +11,11 @@ import ( "errors" "fmt" "go/ast" + "go/importer" "go/parser" "go/token" "go/types" + "io" "os" "os/exec" "path/filepath" @@ -52,6 +54,13 @@ func Load(config Config, patterns ...string) ([]*Package, error) { return nil, err } + goPkgMap := make(map[string]*goPackage, len(listed)) + for i := range listed { + goPkgMap[listed[i].ImportPath] = &listed[i] + } + + archiveImp := archiveImporter(config.FSet, goPkgMap) + pkgs := make(map[string]*Package) var importMap map[string]string @@ -72,18 +81,28 @@ func Load(config Config, patterns ...string) ([]*Package, error) { } } - pkg, ok := pkgs[path] - if !ok { - return nil, errors.New("package not found") + if pkg, ok := pkgs[path]; ok { + return pkg.Types, nil } - return pkg.Types, nil + return archiveImp.Import(path) }), } for _, l := range listed { importMap = l.ImportMap + if l.DepOnly { + pkg, err := loadFromExport(&l, archiveImp) + if err != nil { + return nil, err + } + + pkgs[pkg.Path] = pkg + + continue + } + pkg, err := l.Package(config.FSet) if err != nil { return nil, err @@ -118,6 +137,39 @@ func (i importerFunc) Import(path string) (*types.Package, error) { return i(path) } +func archiveImporter(fset *token.FileSet, goPkgs map[string]*goPackage) types.Importer { + return importer.ForCompiler(fset, "gc", func(path string) (io.ReadCloser, error) { + if path == "unsafe" { + return nil, errors.New("unsafe is built-in") + } + + gp, ok := goPkgs[path] + if !ok { + return nil, fmt.Errorf("archive importer: unknown package %q", path) + } + + if gp.Export == "" { + return nil, fmt.Errorf("archive importer: no export data for %q", path) + } + + return os.Open(gp.Export) + }) +} + +func loadFromExport(gp *goPackage, imp types.Importer) (*Package, error) { + typesPkg, err := imp.Import(gp.ImportPath) + if err != nil { + return nil, fmt.Errorf("loading export of %s: %w", gp.ImportPath, err) + } + + return &Package{ + Path: gp.ImportPath, + Name: gp.Name, + Types: typesPkg, + depOnly: true, + }, nil +} + type goPackage struct { Dir string ImportPath string @@ -125,6 +177,7 @@ type goPackage struct { DepOnly bool GoFiles []string CGoFiles []string + Export string Imports []string ImportMap map[string]string Incomplete bool From e72ed200958981f24e526459a77c6f98f62bf23e Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 20 Jun 2026 12:05:13 +0300 Subject: [PATCH 15/47] rename -testo flag to -pkg --- cmd/testo/internal/cli/commands.go | 4 ++-- cmd/testo/internal/loader/loader.go | 8 ++------ cmd/testo/internal/packageslite/packages_test.go | 2 -- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index 9b1455f..4dd6134 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -18,14 +18,14 @@ func init() { Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") + f.StringVar(&cmd.Load.Pkg, "pkg", defaultTesto, "testo package") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&cmd.JSON, "json", false, "output json") }) Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo import path") + f.StringVar(&cmd.Load.Pkg, "testo", defaultTesto, "testo package") }) Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 855403c..9c616b4 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -30,7 +30,7 @@ func (l *LoadError) Error() string { type LoadSuiteConfig struct { Tags string - Testo string + Pkg string Strict bool } @@ -45,10 +45,6 @@ func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { return nil, err } - // if packages.PrintErrors(pkgs) > 0 { - // os.Exit(1) - // } - var ( suites []Suite diagnostics []Diagnostic @@ -323,7 +319,7 @@ func (c LoadSuiteConfig) asT(f *types.Var) (T, bool) { return T{}, false } - if suite.Obj().Pkg().Path() != c.Testo { + if suite.Obj().Pkg().Path() != c.Pkg { return T{}, false } diff --git a/cmd/testo/internal/packageslite/packages_test.go b/cmd/testo/internal/packageslite/packages_test.go index 65ea0d5..200f849 100644 --- a/cmd/testo/internal/packageslite/packages_test.go +++ b/cmd/testo/internal/packageslite/packages_test.go @@ -6,8 +6,6 @@ import ( ) func BenchmarkLoad(b *testing.B) { - b.Chdir("/Users/vzbarashchenko/Code/ozon/mass-crm-api") - conf := Config{ FSet: token.NewFileSet(), Tags: "e2e,smoke,functional,integration", From 79b232bd14156f95e198e810557e5caf7ec60875 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 20 Jun 2026 14:26:29 +0300 Subject: [PATCH 16/47] run draft --- cmd/testo/internal/cli/commands.go | 214 +++++++++++++++++++++++++++- cmd/testo/internal/loader/loader.go | 26 ++-- 2 files changed, 222 insertions(+), 18 deletions(-) diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index 4dd6134..41e2772 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -5,9 +5,12 @@ import ( "errors" "flag" "fmt" + "maps" "os" + "regexp" "runtime" "runtime/debug" + "strings" "github.com/ozontech/testo/cmd/testo/internal/loader" ) @@ -18,14 +21,20 @@ func init() { Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Pkg, "pkg", defaultTesto, "testo package") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&cmd.JSON, "json", false, "output json") }) + Register("run", "Run testo suites", func(f *flag.FlagSet, cmd *RunCmd) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + f.StringVar(&cmd.Sep, "s", ".", "identifier delimieter") + }) + Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Pkg, "testo", defaultTesto, "testo package") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") }) Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) @@ -47,12 +56,12 @@ func (cmd Version) Run(...string) error { } type Lint struct { - Load loader.LoadSuiteConfig + Load loader.Config JSON bool } func (cmd Lint) Run(patterns ...string) error { - _, err := loader.LoadSuites(cmd.Load, patterns...) + _, err := loader.Load(cmd.Load, patterns...) if err == nil { return nil } @@ -80,11 +89,11 @@ func (cmd Lint) Run(patterns ...string) error { } type Suites struct { - Load loader.LoadSuiteConfig + Load loader.Config } func (cmd Suites) Run(patterns ...string) error { - suites, err := loader.LoadSuites(cmd.Load, patterns...) + suites, err := loader.Load(cmd.Load, patterns...) if err != nil { return err } @@ -133,3 +142,196 @@ func (cmd Suites) Run(patterns ...string) error { return nil } + +type RunCmd struct { + Load loader.Config + Sep string +} + +func (cmd RunCmd) Run(patterns ...string) error { + ids, err := cmd.ids(patterns...) + if err != nil { + return err + } + + if len(ids) == 0 { + return errors.New("at least one pattern is required") + } + + suites, err := loader.Load(cmd.Load, "./...") + if err != nil { + return err + } + + type Matched struct { + Suite loader.Suite + Tests map[string]struct{} + } + + matched := make(map[string]Matched) + + for _, s := range suites { + for _, id := range ids { + tests, ok := id.match(s) + + if !ok { + continue + } + + key := s.Package + "." + s.Name + + if m, ok := matched[key]; ok { + maps.Copy(m.Tests, tests) + } else { + matched[key] = Matched{ + Suite: s, + Tests: tests, + } + } + } + } + + if len(matched) == 0 { + return errors.New("no suites matched") + } + + var suiteRunners []string + var tests []string + + for _, m := range matched { + suiteRunners = append(suiteRunners, "Test"+m.Suite.Name) + + for t := range m.Tests { + tests = append(tests, t) + } + } + + args := []string{ + "go", + "test", + "./...", + "-run", + fmt.Sprintf("^(%s)$", strings.Join(suiteRunners, "|")), + } + + if len(tests) > 0 { + args = append( + args, + "-testo.m", + fmt.Sprintf("^(%s)$", strings.Join(tests, "|")), + ) + } + + fmt.Println(strings.Join(args, " ")) + + return nil +} + +func (cmd RunCmd) ids(patterns ...string) ([]runID, error) { + ids := make([]runID, 0, len(patterns)) + + for _, p := range patterns { + parsed, err := cmd.id(p) + if err != nil { + return nil, fmt.Errorf("parse %q: %w", p, err) + } + + ids = append(ids, parsed...) + } + + return ids, nil +} + +func (cmd RunCmd) id(pattern string) ([]runID, error) { + fields := strings.Split(pattern, cmd.Sep) + + switch len(fields) { + case 1: // suite + suite, err := regexp.Compile(fields[0]) + if err != nil { + return nil, err + } + + return []runID{{ + Suite: suite, + }}, nil + + case 2: // package.suite || suite.test + first, err := regexp.Compile(fields[0]) + if err != nil { + return nil, err + } + + second, err := regexp.Compile(fields[1]) + if err != nil { + return nil, err + } + + return []runID{ + { + Package: first, + Suite: second, + }, + { + Suite: first, + Test: second, + }, + }, nil + + case 3: // package.suite.test + pkg, err := regexp.Compile(fields[0]) + if err != nil { + return nil, err + } + + suite, err := regexp.Compile(fields[1]) + if err != nil { + return nil, err + } + + test, err := regexp.Compile(fields[2]) + if err != nil { + return nil, err + } + + return []runID{{ + Package: pkg, + Suite: suite, + Test: test, + }}, nil + + default: + return nil, errors.New("invalid syntax") + } +} + +type runID struct { + Package *regexp.Regexp + Suite *regexp.Regexp + Test *regexp.Regexp +} + +func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { + if id.Package != nil && !id.Package.MatchString(suite.Package) { + return nil, false + } + + if id.Suite != nil && !id.Suite.MatchString(suite.Name) { + return nil, false + } + + if id.Test == nil { + return nil, true + } + + tests = make(map[string]struct{}) + + for _, t := range suite.Tests { + if id.Test.MatchString(t.Name) { + tests[t.Name] = struct{}{} + ok = true + } + } + + return tests, ok +} diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 9c616b4..1e85dfa 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -28,13 +28,13 @@ func (l *LoadError) Error() string { return strings.Join(msgs, "\n") } -type LoadSuiteConfig struct { +type Config struct { Tags string - Pkg string + Testo string Strict bool } -func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { +func Load(cfg Config, patterns ...string) ([]Suite, error) { fset := token.NewFileSet() pkgs, err := packageslite.Load(packageslite.Config{ @@ -88,7 +88,7 @@ func LoadSuites(cfg LoadSuiteConfig, patterns ...string) ([]Suite, error) { return suites, nil } -func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { +func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { named, ok := obj.Type().(*types.Named) if !ok { return Suite{}, nil, false @@ -116,8 +116,9 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { } suite := Suite{ - Name: named.Obj().Name(), - T: t, + Package: obj.Pkg().Name(), + Name: named.Obj().Name(), + T: t, } cases, diagnostics, fatal := c.collectCases(named) @@ -305,7 +306,7 @@ func (c LoadSuiteConfig) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { return suite, diagnostics, true } -func (c LoadSuiteConfig) asT(f *types.Var) (T, bool) { +func (c Config) asT(f *types.Var) (T, bool) { if !f.Embedded() { return T{}, false } @@ -319,7 +320,7 @@ func (c LoadSuiteConfig) asT(f *types.Var) (T, bool) { return T{}, false } - if suite.Obj().Pkg().Path() != c.Pkg { + if suite.Obj().Pkg().Path() != c.Testo { return T{}, false } @@ -340,7 +341,7 @@ type Case struct { Type types.Type } -func (c LoadSuiteConfig) collectCases( +func (c Config) collectCases( suite *types.Named, ) (cases Cases, diagnostics []Diagnostic, fatal bool) { cases = make(Cases) @@ -566,9 +567,10 @@ func (tm TestsMissing) String() string { func (TestsMissing) issue() {} type Suite struct { - Name string - Tests []SuiteTest - T T + Package string + Name string + Tests []SuiteTest + T T } type T struct { From 4913154a32fe969451455c2301c3978eea7b154e Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 20 Jun 2026 14:51:17 +0300 Subject: [PATCH 17/47] run draft --- cmd/testo/internal/cli/cli.go | 37 ++-- cmd/testo/internal/cli/commands.go | 188 +++++++++++++++----- cmd/testo/internal/loader/loader.go | 98 ++++++++++ cmd/testo/internal/packageslite/packages.go | 48 ++--- 4 files changed, 297 insertions(+), 74 deletions(-) diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go index 8b401ce..884cf1d 100644 --- a/cmd/testo/internal/cli/cli.go +++ b/cmd/testo/internal/cli/cli.go @@ -72,7 +72,7 @@ type registered struct { Run func(args ...string) error } -func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) { +func Register[C Command](name, desc, usage string, flags func(f *flag.FlagSet, cmd *C)) { var command C commands[name] = registered{ @@ -90,19 +90,15 @@ func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) f.VisitAll(func(*flag.Flag) { hasFlags = true }) - if !hasFlags { - fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) + fmt.Fprintf(f.Output(), " %s %s %s\n", os.Args[0], name, usage) - return + if hasFlags { + fmt.Fprintln(f.Output(), "\nFlags:") + f.PrintDefaults() } - - fmt.Fprintf(f.Output(), " %s %s [flags]\n\n", os.Args[0], name) - fmt.Fprintln(f.Output(), "Flags:") - - f.PrintDefaults() } - if err := f.Parse(args); err != nil { + if err := parseFlagSet(f, args); err != nil { return err } @@ -110,3 +106,24 @@ func Register[C Command](name, desc string, flags func(f *flag.FlagSet, cmd *C)) }, } } + +func parseFlagSet(f *flag.FlagSet, args []string) error { + positional := make([]string, 0, len(args)) + + for { + if err := f.Parse(args); err != nil { + return err + } + + args = args[len(args)-f.NArg():] + if len(args) == 0 { + break + } + + positional = append(positional, args[0]) + + args = args[1:] + } + + return f.Parse(positional) +} diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index 41e2772..8f4d412 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -7,9 +7,11 @@ import ( "fmt" "maps" "os" + "os/exec" "regexp" "runtime" "runtime/debug" + "slices" "strings" "github.com/ozontech/testo/cmd/testo/internal/loader" @@ -19,25 +21,40 @@ func init() { const defaultTags = "example,e2e,integration,functional,smoke" const defaultTesto = "github.com/ozontech/testo" - Register("lint", "Run testo linter", func(f *flag.FlagSet, cmd *Lint) { + Register("lint", "Run testo linter", "[flags]", func(f *flag.FlagSet, cmd *Lint) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&cmd.JSON, "json", false, "output json") }) - Register("run", "Run testo suites", func(f *flag.FlagSet, cmd *RunCmd) { + Register( + "run", + "Run testo suites", + `[flags] pattern... [flags] -- [test flags] + +Pattern: + suite suite regex + package.suite package and suite regex + suite.test suite and test regex + package.suite.test package, suite and test regex`, + // "[flags] [package.]suite[.test]... [flags]", + func(f *flag.FlagSet, cmd *RunCmd) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + f.StringVar(&cmd.Sep, "s", ".", "identifier delimieter") + f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") + f.BoolVar(&cmd.Verbose, "v", false, "verbose output") + f.BoolVar(&cmd.JSON, "json", false, "verbose output") + }, + ) + + Register("suites", "Show testo suites", "[flags]", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.StringVar(&cmd.Sep, "s", ".", "identifier delimieter") }) - Register("suites", "Show testo suites", func(f *flag.FlagSet, cmd *Suites) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - }) - - Register("version", "Show testo version", func(*flag.FlagSet, *Version) {}) + Register("version", "Show testo version", "", func(*flag.FlagSet, *Version) {}) } type Version struct{} @@ -144,31 +161,32 @@ func (cmd Suites) Run(patterns ...string) error { } type RunCmd struct { - Load loader.Config - Sep string + Load loader.Config + Sep string + N bool + Verbose bool + JSON bool +} + +type runMatched struct { + Suite loader.Suite + Tests map[string]struct{} } func (cmd RunCmd) Run(patterns ...string) error { - ids, err := cmd.ids(patterns...) + ids, extraFlags, err := cmd.parsePositional(patterns...) if err != nil { return err } - if len(ids) == 0 { - return errors.New("at least one pattern is required") - } - suites, err := loader.Load(cmd.Load, "./...") if err != nil { return err } - type Matched struct { - Suite loader.Suite - Tests map[string]struct{} - } + matched := make(map[string]runMatched) - matched := make(map[string]Matched) + foundByID := make(map[string]bool) for _, s := range suites { for _, id := range ids { @@ -181,9 +199,13 @@ func (cmd RunCmd) Run(patterns ...string) error { key := s.Package + "." + s.Name if m, ok := matched[key]; ok { + foundByID[id.Source] = true + maps.Copy(m.Tests, tests) } else { - matched[key] = Matched{ + foundByID[id.Source] = true + + matched[key] = runMatched{ Suite: s, Tests: tests, } @@ -191,55 +213,127 @@ func (cmd RunCmd) Run(patterns ...string) error { } } + for source, found := range foundByID { + if !found { + return fmt.Errorf("%q did not match any suites", source) + } + } + if len(matched) == 0 { - return errors.New("no suites matched") + for _, s := range suites { + key := s.Package + "." + s.Name + + matched[key] = runMatched{Suite: s} + } + } + + c, err := cmd.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) + if err != nil { + return fmt.Errorf("failed to build go test command: %w", err) } - var suiteRunners []string - var tests []string + if cmd.N { + fmt.Println(c.String()) + + return nil + } + + return c.Run() +} + +func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { + packages := make(map[string]struct{}) + suiteCallers := make(map[string]struct{}) + tests := make(map[string]struct{}) for _, m := range matched { - suiteRunners = append(suiteRunners, "Test"+m.Suite.Name) + for _, r := range m.Suite.Runners { + packages[r.Dir] = struct{}{} + suiteCallers[r.Name] = struct{}{} + } for t := range m.Tests { - tests = append(tests, t) + tests[t] = struct{}{} } } - args := []string{ - "go", - "test", - "./...", - "-run", - fmt.Sprintf("^(%s)$", strings.Join(suiteRunners, "|")), + if len(matched) > 0 && len(packages) == 0 { + return nil, errors.New("suite callers not found") + } + + args := []string{"test", "-tags", cmd.Load.Tags} + + if cmd.Verbose { + args = append(args, "-v") + } + + if cmd.JSON { + args = append(args, "-json") + } + + for p := range packages { + args = append(args, p) + } + + if len(packages) == 0 { + args = append(args, ".") + } + + if len(suiteCallers) > 0 { + args = append( + args, + "-run", + fmt.Sprintf( + "^(%s)$", + strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), + ), + ) } if len(tests) > 0 { args = append( args, "-testo.m", - fmt.Sprintf("^(%s)$", strings.Join(tests, "|")), + fmt.Sprintf( + "^(%s)$", + strings.Join(slices.Sorted(maps.Keys(tests)), "|"), + ), ) } - fmt.Println(strings.Join(args, " ")) + args = append(args, extra...) - return nil + c := exec.Command("go", args...) + + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Env = os.Environ() + + return c, nil } -func (cmd RunCmd) ids(patterns ...string) ([]runID, error) { - ids := make([]runID, 0, len(patterns)) +func (cmd RunCmd) parsePositional(args ...string) ([]runID, []string, error) { + var ( + ids []runID + extra []string + ) + + for i, p := range args { + if strings.HasPrefix(p, "-") { + extra = append(extra, args[i:]...) + + break + } - for _, p := range patterns { parsed, err := cmd.id(p) if err != nil { - return nil, fmt.Errorf("parse %q: %w", p, err) + return nil, nil, fmt.Errorf("parse %q: %w", p, err) } ids = append(ids, parsed...) } - return ids, nil + return ids, extra, nil } func (cmd RunCmd) id(pattern string) ([]runID, error) { @@ -253,7 +347,8 @@ func (cmd RunCmd) id(pattern string) ([]runID, error) { } return []runID{{ - Suite: suite, + Suite: suite, + Source: pattern, }}, nil case 2: // package.suite || suite.test @@ -271,10 +366,12 @@ func (cmd RunCmd) id(pattern string) ([]runID, error) { { Package: first, Suite: second, + Source: pattern, }, { - Suite: first, - Test: second, + Suite: first, + Test: second, + Source: pattern, }, }, nil @@ -298,6 +395,7 @@ func (cmd RunCmd) id(pattern string) ([]runID, error) { Package: pkg, Suite: suite, Test: test, + Source: pattern, }}, nil default: @@ -309,6 +407,8 @@ type runID struct { Package *regexp.Regexp Suite *regexp.Regexp Test *regexp.Regexp + + Source string } func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 1e85dfa..a8052c9 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -1,11 +1,16 @@ package loader import ( + "bytes" "encoding/json" "fmt" + "go/ast" + "go/format" + "go/parser" "go/token" "go/types" "io" + "path/filepath" "slices" "strings" @@ -78,6 +83,8 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { ) }) + cfg.fillSuiteRunners(pkgs, suites) + if len(diagnostics) > 0 { return suites, &LoadError{ FSet: fset, @@ -88,6 +95,87 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { return suites, nil } +func (c Config) filleSuiteRunnersFile( + pkg *packageslite.Package, + fset *token.FileSet, + file *ast.File, + suites []Suite, +) { + var importsTesto bool + + testoName := "testo" + + for _, i := range file.Imports { + if unquote(i.Path.Value) == c.Testo { + importsTesto = true + + if i.Name != nil { + testoName = i.Name.Name + } + + break + } + } + + if !importsTesto { + return + } + + for _, d := range file.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + + if fd.Recv != nil || !parse.IsTest(fd.Name.Name, "Test") { + continue + } + + var buf bytes.Buffer + + format.Node(&buf, fset, fd) + + bufStr := buf.String() + + runSuite := testoName + ".RunSuite" + + for is, s := range suites { + ss := s.Package + "." + s.Name + + if strings.Contains(bufStr, ss) && strings.Contains(bufStr, runSuite) { + suites[is].Runners = append(suites[is].Runners, SuiteRunner{ + Dir: pkg.Dir, + Name: fd.Name.Name, + }) + } + } + } +} + +func (c Config) fillSuiteRunners(pkgs []*packageslite.Package, suites []Suite) { + for _, pkg := range pkgs { + if !slices.Contains(pkg.TestImports, c.Testo) { + continue + } + + fset := token.NewFileSet() + + for _, f := range pkg.TestGoFiles { + file, err := parser.ParseFile( + fset, + filepath.Join(pkg.Dir, f), + nil, + parser.ParseComments, + ) + if err != nil { + continue + } + + c.filleSuiteRunnersFile(pkg, fset, file, suites) + } + } +} + func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { named, ok := obj.Type().(*types.Named) if !ok { @@ -571,6 +659,12 @@ type Suite struct { Name string Tests []SuiteTest T T + Runners []SuiteRunner +} + +type SuiteRunner struct { + Dir string + Name string } type T struct { @@ -602,3 +696,7 @@ func formatNamedType(t *types.Named) string { return pkg + "." + name } + +func unquote(s string) string { + return strings.Trim(s, `"'`) +} diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index 7f60d51..2ae4fe7 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -24,10 +24,13 @@ import ( ) type Package struct { - Path string - Name string - Types *types.Package - Syntax []*ast.File + Path string + Name string + Types *types.Package + Syntax []*ast.File + Dir string + TestImports []string + TestGoFiles []string depOnly bool } @@ -171,17 +174,19 @@ func loadFromExport(gp *goPackage, imp types.Importer) (*Package, error) { } type goPackage struct { - Dir string - ImportPath string - Name string - DepOnly bool - GoFiles []string - CGoFiles []string - Export string - Imports []string - ImportMap map[string]string - Incomplete bool - Error *struct { + Dir string + ImportPath string + Name string + DepOnly bool + GoFiles []string + CGoFiles []string + Export string + Imports []string + ImportMap map[string]string + TestImports []string + TestGoFiles []string + Incomplete bool + Error *struct { Err string } @@ -211,11 +216,14 @@ func (gp goPackage) Package(fset *token.FileSet) (Package, error) { } return Package{ - Path: gp.ImportPath, - Name: gp.Name, - Types: types.NewPackage(gp.ImportPath, gp.Name), - Syntax: files, - depOnly: gp.DepOnly, + Path: gp.ImportPath, + Name: gp.Name, + Types: types.NewPackage(gp.ImportPath, gp.Name), + Syntax: files, + TestImports: gp.TestImports, + TestGoFiles: gp.TestGoFiles, + Dir: gp.Dir, + depOnly: gp.DepOnly, }, nil } From 8047619ea2398f69cc6c820dc8d86c1e79361826 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 21 Jun 2026 13:55:40 +0300 Subject: [PATCH 18/47] allow running without patterns --- cmd/testo/internal/cli/commands.go | 2 +- cmd/testo/internal/loader/loader.go | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index 8f4d412..782a51a 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -45,7 +45,7 @@ Pattern: f.StringVar(&cmd.Sep, "s", ".", "identifier delimieter") f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") f.BoolVar(&cmd.Verbose, "v", false, "verbose output") - f.BoolVar(&cmd.JSON, "json", false, "verbose output") + f.BoolVar(&cmd.JSON, "json", false, "log verbose output and test results in JSON") }, ) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index a8052c9..4e05cda 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -103,14 +103,16 @@ func (c Config) filleSuiteRunnersFile( ) { var importsTesto bool - testoName := "testo" + testoRunSuite := "testo.RunSuite" + + packageName := file.Name.Name for _, i := range file.Imports { if unquote(i.Path.Value) == c.Testo { importsTesto = true if i.Name != nil { - testoName = i.Name.Name + testoRunSuite = i.Name.Name + ".RunSuite" } break @@ -121,6 +123,8 @@ func (c Config) filleSuiteRunnersFile( return } + testoRunSuite = strings.Trim(testoRunSuite, ".") + for _, d := range file.Decls { fd, ok := d.(*ast.FuncDecl) if !ok { @@ -137,13 +141,18 @@ func (c Config) filleSuiteRunnersFile( bufStr := buf.String() - runSuite := testoName + ".RunSuite" + for i, s := range suites { + if !strings.Contains(bufStr, testoRunSuite) { + continue + } - for is, s := range suites { - ss := s.Package + "." + s.Name + id := s.Name + if s.Package != packageName { + id = s.Package + "." + s.Name + } - if strings.Contains(bufStr, ss) && strings.Contains(bufStr, runSuite) { - suites[is].Runners = append(suites[is].Runners, SuiteRunner{ + if strings.Contains(bufStr, id) { + suites[i].Runners = append(suites[i].Runners, SuiteRunner{ Dir: pkg.Dir, Name: fd.Name.Name, }) From 95ec21c5861af0841e29017ca6f0650c9800bae0 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 21 Jun 2026 14:49:39 +0300 Subject: [PATCH 19/47] improve selectors --- cmd/testo/internal/cli/commands.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index 782a51a..ad7653b 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -179,6 +179,12 @@ func (cmd RunCmd) Run(patterns ...string) error { return err } + foundByID := make(map[string]bool) + + for _, id := range ids { + foundByID[id.Source] = false + } + suites, err := loader.Load(cmd.Load, "./...") if err != nil { return err @@ -186,8 +192,6 @@ func (cmd RunCmd) Run(patterns ...string) error { matched := make(map[string]runMatched) - foundByID := make(map[string]bool) - for _, s := range suites { for _, id := range ids { tests, ok := id.match(s) @@ -249,7 +253,7 @@ func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, for _, m := range matched { for _, r := range m.Suite.Runners { packages[r.Dir] = struct{}{} - suiteCallers[r.Name] = struct{}{} + suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} } for t := range m.Tests { @@ -283,10 +287,7 @@ func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, args = append( args, "-run", - fmt.Sprintf( - "^(%s)$", - strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), - ), + strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), ) } From 12b80c982fe46915d215004787f77d1087f63120 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 21 Jun 2026 15:32:00 +0300 Subject: [PATCH 20/47] allow only one pattern --- cmd/testo/README.md | 13 ++++ cmd/testo/internal/cli/commands.go | 97 ++++++++++++------------------ 2 files changed, 52 insertions(+), 58 deletions(-) create mode 100644 cmd/testo/README.md diff --git a/cmd/testo/README.md b/cmd/testo/README.md new file mode 100644 index 0000000..138f70b --- /dev/null +++ b/cmd/testo/README.md @@ -0,0 +1,13 @@ +# Testo CTL + +Command line tool for Testo. + +## Features + +- Linter for common mistakes with Testo (wrong `T` type, mismatched params and more) +- Suites runner +- Suites explorer + +## Usage + +Run `testo -h` to see available commands. diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go index ad7653b..cca6645 100644 --- a/cmd/testo/internal/cli/commands.go +++ b/cmd/testo/internal/cli/commands.go @@ -31,18 +31,17 @@ func init() { Register( "run", "Run testo suites", - `[flags] pattern... [flags] -- [test flags] + `[flags] [pattern] [flags] -- [test flags] Pattern: suite suite regex - package.suite package and suite regex suite.test suite and test regex package.suite.test package, suite and test regex`, // "[flags] [package.]suite[.test]... [flags]", func(f *flag.FlagSet, cmd *RunCmd) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.StringVar(&cmd.Sep, "s", ".", "identifier delimieter") + f.StringVar(&cmd.Sep, "s", ".", "pattern separator") f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") f.BoolVar(&cmd.Verbose, "v", false, "verbose output") f.BoolVar(&cmd.JSON, "json", false, "log verbose output and test results in JSON") @@ -174,17 +173,11 @@ type runMatched struct { } func (cmd RunCmd) Run(patterns ...string) error { - ids, extraFlags, err := cmd.parsePositional(patterns...) + id, extraFlags, err := cmd.parsePositional(patterns...) if err != nil { return err } - foundByID := make(map[string]bool) - - for _, id := range ids { - foundByID[id.Source] = false - } - suites, err := loader.Load(cmd.Load, "./...") if err != nil { return err @@ -192,8 +185,8 @@ func (cmd RunCmd) Run(patterns ...string) error { matched := make(map[string]runMatched) - for _, s := range suites { - for _, id := range ids { + if id != nil { + for _, s := range suites { tests, ok := id.match(s) if !ok { @@ -203,32 +196,28 @@ func (cmd RunCmd) Run(patterns ...string) error { key := s.Package + "." + s.Name if m, ok := matched[key]; ok { - foundByID[id.Source] = true - maps.Copy(m.Tests, tests) } else { - foundByID[id.Source] = true - matched[key] = runMatched{ Suite: s, Tests: tests, } } } - } + } else { + for _, s := range suites { + key := s.Package + "." + s.Name - for source, found := range foundByID { - if !found { - return fmt.Errorf("%q did not match any suites", source) + matched[key] = runMatched{Suite: s} } } if len(matched) == 0 { - for _, s := range suites { - key := s.Package + "." + s.Name - - matched[key] = runMatched{Suite: s} + if id != nil { + return fmt.Errorf("%q did not match any suites", id.Source) } + + return errors.New("testo suites not found") } c, err := cmd.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) @@ -313,12 +302,7 @@ func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, return c, nil } -func (cmd RunCmd) parsePositional(args ...string) ([]runID, []string, error) { - var ( - ids []runID - extra []string - ) - +func (cmd RunCmd) parsePositional(args ...string) (id *runID, extra []string, err error) { for i, p := range args { if strings.HasPrefix(p, "-") { extra = append(extra, args[i:]...) @@ -326,81 +310,78 @@ func (cmd RunCmd) parsePositional(args ...string) ([]runID, []string, error) { break } + if id != nil { + return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) + } + parsed, err := cmd.id(p) if err != nil { return nil, nil, fmt.Errorf("parse %q: %w", p, err) } - ids = append(ids, parsed...) + id = &parsed } - return ids, extra, nil + return id, extra, nil } -func (cmd RunCmd) id(pattern string) ([]runID, error) { +func (cmd RunCmd) id(pattern string) (runID, error) { fields := strings.Split(pattern, cmd.Sep) switch len(fields) { case 1: // suite suite, err := regexp.Compile(fields[0]) if err != nil { - return nil, err + return runID{}, err } - return []runID{{ + return runID{ Suite: suite, Source: pattern, - }}, nil + }, nil - case 2: // package.suite || suite.test - first, err := regexp.Compile(fields[0]) + case 2: // suite.test + suite, err := regexp.Compile(fields[0]) if err != nil { - return nil, err + return runID{}, err } - second, err := regexp.Compile(fields[1]) + test, err := regexp.Compile(fields[1]) if err != nil { - return nil, err + return runID{}, err } - return []runID{ - { - Package: first, - Suite: second, - Source: pattern, - }, - { - Suite: first, - Test: second, - Source: pattern, - }, + return runID{ + Suite: suite, + Test: test, + Source: pattern, }, nil case 3: // package.suite.test pkg, err := regexp.Compile(fields[0]) if err != nil { - return nil, err + return runID{}, err } suite, err := regexp.Compile(fields[1]) if err != nil { - return nil, err + return runID{}, err } test, err := regexp.Compile(fields[2]) if err != nil { - return nil, err + return runID{}, err } - return []runID{{ + return runID{ Package: pkg, Suite: suite, Test: test, Source: pattern, - }}, nil + }, nil default: - return nil, errors.New("invalid syntax") + return runID{}, errors.New("invalid syntax") } } From cfd9349845a7329ac60ff04cf52cbe6c70827d7f Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 22 Jun 2026 00:15:30 +0300 Subject: [PATCH 21/47] improve cli --- cmd/testo/README.md | 4 + cmd/testo/internal/cli/cli.go | 86 +++++- cmd/testo/internal/cli/commands.go | 419 ---------------------------- cmd/testo/main.go | 421 ++++++++++++++++++++++++++++- 4 files changed, 501 insertions(+), 429 deletions(-) delete mode 100644 cmd/testo/internal/cli/commands.go diff --git a/cmd/testo/README.md b/cmd/testo/README.md index 138f70b..dd612d4 100644 --- a/cmd/testo/README.md +++ b/cmd/testo/README.md @@ -2,6 +2,10 @@ Command line tool for Testo. +> [!WARNING] +> This tool is experimental, handle with care; +> may change without warning + ## Features - Linter for common mistakes with Testo (wrong `T` type, mismatched params and more) diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go index 884cf1d..f0c5e1e 100644 --- a/cmd/testo/internal/cli/cli.go +++ b/cmd/testo/internal/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "cmp" _ "embed" "flag" "fmt" @@ -22,7 +23,7 @@ func usage(f *flag.FlagSet) { fmt.Fprintln(&buf, "Available Commands:") for _, cmd := range slices.Sorted(maps.Keys(commands)) { - fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Desc) + fmt.Fprintf(&buf, " %-10s %s\n", cmd, commands[cmd].Short) } f.Output().Write(buf.Bytes()) @@ -50,7 +51,7 @@ func Run() { func run(command string, args ...string) error { r, ok := commands[command] if !ok { - fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand %q\n\n", command) + fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand: %q\n\n", command) usage(flag.CommandLine) os.Exit(2) @@ -68,32 +69,91 @@ type Command interface { var commands = make(map[string]registered) type registered struct { - Desc string - Run func(args ...string) error + Short string + Run func(args ...string) error } -func Register[C Command](name, desc, usage string, flags func(f *flag.FlagSet, cmd *C)) { +type config struct { + Short string + Usage string + Long string + Args ArgsFunc +} + +type Option func(conf *config) + +func WithShort(short string) Option { + return func(conf *config) { + conf.Short = short + } +} + +func WithUsage(u string) Option { + return func(conf *config) { + conf.Usage = u + } +} + +func WithLong(u string) Option { + return func(conf *config) { + conf.Long = u + } +} + +func WithoutArgs() Option { + return func(conf *config) { + conf.Args = func(args ...string) error { + if len(args) == 0 { + return nil + } + + return fmt.Errorf("unexpected argument: %q", args[0]) + } + } +} + +type ArgsFunc func(args ...string) error + +func Register[C Command](name string, flags func(f *flag.FlagSet, cmd *C), options ...Option) { + var conf config + + for _, o := range options { + o(&conf) + } + var command C commands[name] = registered{ - Desc: desc, + Short: conf.Short, Run: func(args ...string) error { f := flag.NewFlagSet(name, flag.ExitOnError) flags(f, &command) f.Usage = func() { - fmt.Fprintf(f.Output(), "%s\n\n", desc) + long := cmp.Or(conf.Long, conf.Short) + + if long != "" { + fmt.Fprintf(f.Output(), "%s\n\n", long) + } + fmt.Fprintln(f.Output(), "Usage:") var hasFlags bool f.VisitAll(func(*flag.Flag) { hasFlags = true }) - fmt.Fprintf(f.Output(), " %s %s %s\n", os.Args[0], name, usage) + if conf.Usage != "" { + fmt.Fprintf(f.Output(), " %s %s %s\n", os.Args[0], name, conf.Usage) + } else if hasFlags { + fmt.Fprintf(f.Output(), " %s %s [flags]\n", os.Args[0], name) + } else { + fmt.Fprintf(f.Output(), " %s %s\n", os.Args[0], name) + } if hasFlags { fmt.Fprintln(f.Output(), "\nFlags:") + f.PrintDefaults() } } @@ -102,7 +162,15 @@ func Register[C Command](name, desc, usage string, flags func(f *flag.FlagSet, c return err } - return command.Run(f.Args()...) + positional := f.Args() + + if conf.Args != nil { + if err := conf.Args(positional...); err != nil { + return fmt.Errorf("%s %s: %w", os.Args[0], name, err) + } + } + + return command.Run(positional...) }, } } diff --git a/cmd/testo/internal/cli/commands.go b/cmd/testo/internal/cli/commands.go deleted file mode 100644 index cca6645..0000000 --- a/cmd/testo/internal/cli/commands.go +++ /dev/null @@ -1,419 +0,0 @@ -package cli - -import ( - "bufio" - "errors" - "flag" - "fmt" - "maps" - "os" - "os/exec" - "regexp" - "runtime" - "runtime/debug" - "slices" - "strings" - - "github.com/ozontech/testo/cmd/testo/internal/loader" -) - -func init() { - const defaultTags = "example,e2e,integration,functional,smoke" - const defaultTesto = "github.com/ozontech/testo" - - Register("lint", "Run testo linter", "[flags]", func(f *flag.FlagSet, cmd *Lint) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") - f.BoolVar(&cmd.JSON, "json", false, "output json") - }) - - Register( - "run", - "Run testo suites", - `[flags] [pattern] [flags] -- [test flags] - -Pattern: - suite suite regex - suite.test suite and test regex - package.suite.test package, suite and test regex`, - // "[flags] [package.]suite[.test]... [flags]", - func(f *flag.FlagSet, cmd *RunCmd) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.StringVar(&cmd.Sep, "s", ".", "pattern separator") - f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") - f.BoolVar(&cmd.Verbose, "v", false, "verbose output") - f.BoolVar(&cmd.JSON, "json", false, "log verbose output and test results in JSON") - }, - ) - - Register("suites", "Show testo suites", "[flags]", func(f *flag.FlagSet, cmd *Suites) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - }) - - Register("version", "Show testo version", "", func(*flag.FlagSet, *Version) {}) -} - -type Version struct{} - -func (cmd Version) Run(...string) error { - version := "unknown" - - info, ok := debug.ReadBuildInfo() - if ok { - version = info.Main.Version - } - - fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) - - return nil -} - -type Lint struct { - Load loader.Config - JSON bool -} - -func (cmd Lint) Run(patterns ...string) error { - _, err := loader.Load(cmd.Load, patterns...) - if err == nil { - return nil - } - - var errLoad *loader.LoadError - - if errors.As(err, &errLoad) { - w := bufio.NewWriter(os.Stdout) - - for _, d := range errLoad.Diagnostics { - if cmd.JSON { - d.FormatJSON(w, errLoad.FSet) - } else { - w.WriteString(d.Format(errLoad.FSet)) - w.WriteString("\n") - } - } - - w.Flush() - - os.Exit(1) - } - - return err -} - -type Suites struct { - Load loader.Config -} - -func (cmd Suites) Run(patterns ...string) error { - suites, err := loader.Load(cmd.Load, patterns...) - if err != nil { - return err - } - - for i, s := range suites { - w := bufio.NewWriter(os.Stdout) - - fmt.Fprintln(w, "[S] "+s.Name) - - for i, t := range s.Tests { - symbol := "└" - fallback := " " - - if i != len(s.Tests)-1 { - symbol = "├" - fallback = "│" - } - - symbol += "──" - - if t.Parametrized { - fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) - - for j, p := range t.Parameters { - symbol := "└" - - if j != len(t.Parameters)-1 { - symbol = "├" - } - - symbol += "──" - - fmt.Fprintf(w, " %s %s [P] %s\n", fallback, symbol, p) - } - } else { - fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) - } - } - - if i != len(suites)-1 { - fmt.Fprintln(w) - } - - w.Flush() - } - - return nil -} - -type RunCmd struct { - Load loader.Config - Sep string - N bool - Verbose bool - JSON bool -} - -type runMatched struct { - Suite loader.Suite - Tests map[string]struct{} -} - -func (cmd RunCmd) Run(patterns ...string) error { - id, extraFlags, err := cmd.parsePositional(patterns...) - if err != nil { - return err - } - - suites, err := loader.Load(cmd.Load, "./...") - if err != nil { - return err - } - - matched := make(map[string]runMatched) - - if id != nil { - for _, s := range suites { - tests, ok := id.match(s) - - if !ok { - continue - } - - key := s.Package + "." + s.Name - - if m, ok := matched[key]; ok { - maps.Copy(m.Tests, tests) - } else { - matched[key] = runMatched{ - Suite: s, - Tests: tests, - } - } - } - } else { - for _, s := range suites { - key := s.Package + "." + s.Name - - matched[key] = runMatched{Suite: s} - } - } - - if len(matched) == 0 { - if id != nil { - return fmt.Errorf("%q did not match any suites", id.Source) - } - - return errors.New("testo suites not found") - } - - c, err := cmd.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) - if err != nil { - return fmt.Errorf("failed to build go test command: %w", err) - } - - if cmd.N { - fmt.Println(c.String()) - - return nil - } - - return c.Run() -} - -func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { - packages := make(map[string]struct{}) - suiteCallers := make(map[string]struct{}) - tests := make(map[string]struct{}) - - for _, m := range matched { - for _, r := range m.Suite.Runners { - packages[r.Dir] = struct{}{} - suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} - } - - for t := range m.Tests { - tests[t] = struct{}{} - } - } - - if len(matched) > 0 && len(packages) == 0 { - return nil, errors.New("suite callers not found") - } - - args := []string{"test", "-tags", cmd.Load.Tags} - - if cmd.Verbose { - args = append(args, "-v") - } - - if cmd.JSON { - args = append(args, "-json") - } - - for p := range packages { - args = append(args, p) - } - - if len(packages) == 0 { - args = append(args, ".") - } - - if len(suiteCallers) > 0 { - args = append( - args, - "-run", - strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), - ) - } - - if len(tests) > 0 { - args = append( - args, - "-testo.m", - fmt.Sprintf( - "^(%s)$", - strings.Join(slices.Sorted(maps.Keys(tests)), "|"), - ), - ) - } - - args = append(args, extra...) - - c := exec.Command("go", args...) - - c.Stdout = os.Stdout - c.Stderr = os.Stderr - c.Env = os.Environ() - - return c, nil -} - -func (cmd RunCmd) parsePositional(args ...string) (id *runID, extra []string, err error) { - for i, p := range args { - if strings.HasPrefix(p, "-") { - extra = append(extra, args[i:]...) - - break - } - - if id != nil { - return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) - } - - parsed, err := cmd.id(p) - if err != nil { - return nil, nil, fmt.Errorf("parse %q: %w", p, err) - } - - id = &parsed - } - - return id, extra, nil -} - -func (cmd RunCmd) id(pattern string) (runID, error) { - fields := strings.Split(pattern, cmd.Sep) - - switch len(fields) { - case 1: // suite - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - return runID{ - Suite: suite, - Source: pattern, - }, nil - - case 2: // suite.test - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - test, err := regexp.Compile(fields[1]) - if err != nil { - return runID{}, err - } - - return runID{ - Suite: suite, - Test: test, - Source: pattern, - }, nil - - case 3: // package.suite.test - pkg, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - suite, err := regexp.Compile(fields[1]) - if err != nil { - return runID{}, err - } - - test, err := regexp.Compile(fields[2]) - if err != nil { - return runID{}, err - } - - return runID{ - Package: pkg, - Suite: suite, - Test: test, - Source: pattern, - }, nil - - default: - return runID{}, errors.New("invalid syntax") - } -} - -type runID struct { - Package *regexp.Regexp - Suite *regexp.Regexp - Test *regexp.Regexp - - Source string -} - -func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { - if id.Package != nil && !id.Package.MatchString(suite.Package) { - return nil, false - } - - if id.Suite != nil && !id.Suite.MatchString(suite.Name) { - return nil, false - } - - if id.Test == nil { - return nil, true - } - - tests = make(map[string]struct{}) - - for _, t := range suite.Tests { - if id.Test.MatchString(t.Name) { - tests[t.Name] = struct{}{} - ok = true - } - } - - return tests, ok -} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 775f31c..9a89f3c 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -1,7 +1,426 @@ package main -import "github.com/ozontech/testo/cmd/testo/internal/cli" +import ( + "bufio" + "errors" + "flag" + "fmt" + "maps" + "os" + "os/exec" + "regexp" + "runtime" + "runtime/debug" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) func main() { cli.Run() } + +func init() { + const defaultTags = "example,e2e,integration,functional,smoke" + const defaultTesto = "github.com/ozontech/testo" + + cli.Register("lint", func(f *flag.FlagSet, cmd *Lint) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") + f.BoolVar(&cmd.JSON, "json", false, "output json") + }, cli.WithShort("Run testo linter")) + + cli.Register("run", func(f *flag.FlagSet, cmd *RunCmd) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + f.StringVar(&cmd.Sep, "s", ".", "pattern separator") + f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") + f.BoolVar(&cmd.Verbose, "v", false, "verbose output") + f.BoolVar(&cmd.JSON, "json", false, "log verbose output and test results in JSON") + }, + cli.WithShort("Run testo suites"), + cli.WithUsage(`[flags] [pattern] [flags] -- [test flags] + +Pattern: + suite suite regex + suite.test suite and test regex + package.suite.test package, suite and test regex`), + ) + + cli.Register("suites", func(f *flag.FlagSet, cmd *Suites) { + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") + f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + }, cli.WithShort("Show testo suites")) + + cli.Register( + "version", + func(*flag.FlagSet, *Version) {}, + cli.WithShort("Show testo version"), + cli.WithoutArgs(), + ) +} + +type Version struct{} + +func (cmd Version) Run(...string) error { + version := "unknown" + + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + + fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) + + return nil +} + +type Lint struct { + Load loader.Config + JSON bool +} + +func (cmd Lint) Run(patterns ...string) error { + _, err := loader.Load(cmd.Load, patterns...) + if err == nil { + return nil + } + + var errLoad *loader.LoadError + + if errors.As(err, &errLoad) { + w := bufio.NewWriter(os.Stdout) + + for _, d := range errLoad.Diagnostics { + if cmd.JSON { + d.FormatJSON(w, errLoad.FSet) + } else { + w.WriteString(d.Format(errLoad.FSet)) + w.WriteString("\n") + } + } + + w.Flush() + + os.Exit(1) + } + + return err +} + +type Suites struct { + Load loader.Config +} + +func (cmd Suites) Run(patterns ...string) error { + suites, err := loader.Load(cmd.Load, patterns...) + if err != nil { + return err + } + + for i, s := range suites { + w := bufio.NewWriter(os.Stdout) + + fmt.Fprintln(w, "[S] "+s.Name) + + for i, t := range s.Tests { + symbol := "└" + fallback := " " + + if i != len(s.Tests)-1 { + symbol = "├" + fallback = "│" + } + + symbol += "──" + + if t.Parametrized { + fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) + + for j, p := range t.Parameters { + symbol := "└" + + if j != len(t.Parameters)-1 { + symbol = "├" + } + + symbol += "──" + + fmt.Fprintf(w, " %s %s [P] %s\n", fallback, symbol, p) + } + } else { + fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) + } + } + + if i != len(suites)-1 { + fmt.Fprintln(w) + } + + w.Flush() + } + + return nil +} + +type RunCmd struct { + Load loader.Config + Sep string + N bool + Verbose bool + JSON bool +} + +type runMatched struct { + Suite loader.Suite + Tests map[string]struct{} +} + +func (cmd RunCmd) Run(patterns ...string) error { + id, extraFlags, err := cmd.parsePositional(patterns...) + if err != nil { + return err + } + + suites, err := loader.Load(cmd.Load, "./...") + if err != nil { + return err + } + + matched := make(map[string]runMatched) + + if id != nil { + for _, s := range suites { + tests, ok := id.match(s) + + if !ok { + continue + } + + key := s.Package + "." + s.Name + + if m, ok := matched[key]; ok { + maps.Copy(m.Tests, tests) + } else { + matched[key] = runMatched{ + Suite: s, + Tests: tests, + } + } + } + } else { + for _, s := range suites { + key := s.Package + "." + s.Name + + matched[key] = runMatched{Suite: s} + } + } + + if len(matched) == 0 { + if id != nil { + return fmt.Errorf("%q did not match any suites", id.Source) + } + + return errors.New("testo suites not found") + } + + c, err := cmd.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) + if err != nil { + return fmt.Errorf("failed to build go test command: %w", err) + } + + if cmd.N { + fmt.Println(c.String()) + + return nil + } + + return c.Run() +} + +func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { + packages := make(map[string]struct{}) + suiteCallers := make(map[string]struct{}) + tests := make(map[string]struct{}) + + for _, m := range matched { + for _, r := range m.Suite.Runners { + packages[r.Dir] = struct{}{} + suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} + } + + for t := range m.Tests { + tests[t] = struct{}{} + } + } + + if len(matched) > 0 && len(packages) == 0 { + return nil, errors.New("suite callers not found") + } + + args := []string{"test", "-tags", cmd.Load.Tags} + + if cmd.Verbose { + args = append(args, "-v") + } + + if cmd.JSON { + args = append(args, "-json") + } + + for p := range packages { + args = append(args, p) + } + + if len(packages) == 0 { + args = append(args, ".") + } + + if len(suiteCallers) > 0 { + args = append( + args, + "-run", + strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), + ) + } + + if len(tests) > 0 { + args = append( + args, + "-testo.m", + fmt.Sprintf( + "^(%s)$", + strings.Join(slices.Sorted(maps.Keys(tests)), "|"), + ), + ) + } + + args = append(args, extra...) + + c := exec.Command("go", args...) + + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Env = os.Environ() + + return c, nil +} + +func (cmd RunCmd) parsePositional(args ...string) (id *runID, extra []string, err error) { + for i, p := range args { + if strings.HasPrefix(p, "-") { + extra = append(extra, args[i:]...) + + break + } + + if id != nil { + return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) + } + + parsed, err := cmd.id(p) + if err != nil { + return nil, nil, fmt.Errorf("parse %q: %w", p, err) + } + + id = &parsed + } + + return id, extra, nil +} + +func (cmd RunCmd) id(pattern string) (runID, error) { + fields := strings.Split(pattern, cmd.Sep) + + switch len(fields) { + case 1: // suite + suite, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + return runID{ + Suite: suite, + Source: pattern, + }, nil + + case 2: // suite.test + suite, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + test, err := regexp.Compile(fields[1]) + if err != nil { + return runID{}, err + } + + return runID{ + Suite: suite, + Test: test, + Source: pattern, + }, nil + + case 3: // package.suite.test + pkg, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + suite, err := regexp.Compile(fields[1]) + if err != nil { + return runID{}, err + } + + test, err := regexp.Compile(fields[2]) + if err != nil { + return runID{}, err + } + + return runID{ + Package: pkg, + Suite: suite, + Test: test, + Source: pattern, + }, nil + + default: + return runID{}, errors.New("invalid syntax") + } +} + +type runID struct { + Package *regexp.Regexp + Suite *regexp.Regexp + Test *regexp.Regexp + + Source string +} + +func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { + if id.Package != nil && !id.Package.MatchString(suite.Package) { + return nil, false + } + + if id.Suite != nil && !id.Suite.MatchString(suite.Name) { + return nil, false + } + + if id.Test == nil { + return nil, true + } + + tests = make(map[string]struct{}) + + for _, t := range suite.Tests { + if id.Test.MatchString(t.Name) { + tests[t.Name] = struct{}{} + ok = true + } + } + + return tests, ok +} From 6af8c4554eab75bf4e3690c2380dc534cfb4d633 Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 22 Jun 2026 00:34:55 +0300 Subject: [PATCH 22/47] add exit errors --- cmd/testo/internal/cli/cli.go | 14 +++++++--- cmd/testo/internal/cli/errors.go | 39 +++++++++++++++++++++++++++ cmd/testo/main.go | 45 ++++++++++++++++---------------- 3 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 cmd/testo/internal/cli/errors.go diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go index f0c5e1e..37f3a9f 100644 --- a/cmd/testo/internal/cli/cli.go +++ b/cmd/testo/internal/cli/cli.go @@ -4,6 +4,7 @@ import ( "bytes" "cmp" _ "embed" + "errors" "flag" "fmt" "maps" @@ -43,8 +44,15 @@ func Run() { } if err := run(os.Args[1], os.Args[2:]...); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) + var errExit ExitError + + if !errors.As(err, &errExit) { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + errExit.Print() + os.Exit(errExit.Code) } } @@ -114,7 +122,7 @@ func WithoutArgs() Option { type ArgsFunc func(args ...string) error -func Register[C Command](name string, flags func(f *flag.FlagSet, cmd *C), options ...Option) { +func Add[C Command](name string, flags func(f *flag.FlagSet, cmd *C), options ...Option) { var conf config for _, o := range options { diff --git a/cmd/testo/internal/cli/errors.go b/cmd/testo/internal/cli/errors.go new file mode 100644 index 0000000..8644c1c --- /dev/null +++ b/cmd/testo/internal/cli/errors.go @@ -0,0 +1,39 @@ +package cli + +import ( + "fmt" + "os" +) + +type ExitError struct { + Code int + + stdout string + stderr string +} + +func (e ExitError) Error() string { + return fmt.Sprintf("exit code %d", e.Code) +} + +func Exit(code int) *ExitError { + return &ExitError{ + Code: code, + } +} + +func (e ExitError) Stdout(s string) ExitError { + e.stdout = s + + return e +} + +func (e ExitError) Print() { + if e.stdout != "" { + fmt.Fprint(os.Stdout, e.stdout) + } + + if e.stderr != "" { + fmt.Fprint(os.Stderr, e.stderr) + } +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 9a89f3c..1b329ab 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "errors" "flag" "fmt" @@ -26,14 +27,14 @@ func init() { const defaultTags = "example,e2e,integration,functional,smoke" const defaultTesto = "github.com/ozontech/testo" - cli.Register("lint", func(f *flag.FlagSet, cmd *Lint) { + cli.Add("lint", func(f *flag.FlagSet, cmd *Lint) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&cmd.JSON, "json", false, "output json") }, cli.WithShort("Run testo linter")) - cli.Register("run", func(f *flag.FlagSet, cmd *RunCmd) { + cli.Add("run", func(f *flag.FlagSet, cmd *Run) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.StringVar(&cmd.Sep, "s", ".", "pattern separator") @@ -50,12 +51,12 @@ Pattern: package.suite.test package, suite and test regex`), ) - cli.Register("suites", func(f *flag.FlagSet, cmd *Suites) { + cli.Add("suites", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") }, cli.WithShort("Show testo suites")) - cli.Register( + cli.Add( "version", func(*flag.FlagSet, *Version) {}, cli.WithShort("Show testo version"), @@ -91,24 +92,22 @@ func (cmd Lint) Run(patterns ...string) error { var errLoad *loader.LoadError - if errors.As(err, &errLoad) { - w := bufio.NewWriter(os.Stdout) - - for _, d := range errLoad.Diagnostics { - if cmd.JSON { - d.FormatJSON(w, errLoad.FSet) - } else { - w.WriteString(d.Format(errLoad.FSet)) - w.WriteString("\n") - } - } + if !errors.As(err, &errLoad) { + return err + } - w.Flush() + var buf bytes.Buffer - os.Exit(1) + for _, d := range errLoad.Diagnostics { + if cmd.JSON { + d.FormatJSON(&buf, errLoad.FSet) + } else { + buf.WriteString(d.Format(errLoad.FSet)) + buf.WriteString("\n") + } } - return err + return cli.Exit(1).Stdout(buf.String()) } type Suites struct { @@ -166,7 +165,7 @@ func (cmd Suites) Run(patterns ...string) error { return nil } -type RunCmd struct { +type Run struct { Load loader.Config Sep string N bool @@ -179,7 +178,7 @@ type runMatched struct { Tests map[string]struct{} } -func (cmd RunCmd) Run(patterns ...string) error { +func (cmd Run) Run(patterns ...string) error { id, extraFlags, err := cmd.parsePositional(patterns...) if err != nil { return err @@ -241,7 +240,7 @@ func (cmd RunCmd) Run(patterns ...string) error { return c.Run() } -func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { +func (cmd Run) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { packages := make(map[string]struct{}) suiteCallers := make(map[string]struct{}) tests := make(map[string]struct{}) @@ -309,7 +308,7 @@ func (cmd RunCmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, return c, nil } -func (cmd RunCmd) parsePositional(args ...string) (id *runID, extra []string, err error) { +func (cmd Run) parsePositional(args ...string) (id *runID, extra []string, err error) { for i, p := range args { if strings.HasPrefix(p, "-") { extra = append(extra, args[i:]...) @@ -332,7 +331,7 @@ func (cmd RunCmd) parsePositional(args ...string) (id *runID, extra []string, er return id, extra, nil } -func (cmd RunCmd) id(pattern string) (runID, error) { +func (cmd Run) id(pattern string) (runID, error) { fields := strings.Split(pattern, cmd.Sep) switch len(fields) { From fc0a36fc963e47878341c930b6d3347916c201f7 Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 22 Jun 2026 09:41:45 +0300 Subject: [PATCH 23/47] minor improvements --- cmd/testo/internal/cli/errors.go | 4 +-- cmd/testo/internal/loader/loader.go | 53 +++++++++++++---------------- cmd/testo/internal/typeutil/util.go | 23 +++++++++++++ cmd/testo/main.go | 8 ++--- 4 files changed, 52 insertions(+), 36 deletions(-) create mode 100644 cmd/testo/internal/typeutil/util.go diff --git a/cmd/testo/internal/cli/errors.go b/cmd/testo/internal/cli/errors.go index 8644c1c..d1d46d5 100644 --- a/cmd/testo/internal/cli/errors.go +++ b/cmd/testo/internal/cli/errors.go @@ -16,8 +16,8 @@ func (e ExitError) Error() string { return fmt.Sprintf("exit code %d", e.Code) } -func Exit(code int) *ExitError { - return &ExitError{ +func Exit(code int) ExitError { + return ExitError{ Code: code, } } diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 4e05cda..ada0d67 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/ozontech/testo/cmd/testo/internal/packageslite" + "github.com/ozontech/testo/cmd/testo/internal/typeutil" "github.com/ozontech/testo/internal/parse" ) @@ -27,7 +28,11 @@ func (l *LoadError) Error() string { msgs := make([]string, 0, len(l.Diagnostics)) for _, d := range l.Diagnostics { - msgs = append(msgs, d.Format(l.FSet)) + var buf bytes.Buffer + + d.Print(&buf, l.FSet) + + msgs = append(msgs, buf.String()) } return strings.Join(msgs, "\n") @@ -267,8 +272,8 @@ func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { fmt.Sprintf( "%s must accept %s, got %s", name, - formatType(suite.T.Type), - formatType(in.Type()), + typeutil.Format(suite.T.Type), + typeutil.Format(in.Type()), ), ), }) @@ -288,8 +293,8 @@ func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { fmt.Sprintf( "%s must accept %s, got %s", name, - formatType(suite.T.Type), - formatType(first.Type()), + typeutil.Format(suite.T.Type), + typeutil.Format(first.Type()), ), ), }) @@ -317,7 +322,7 @@ func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { var invalidParams bool - var paramNames []string + var parameters []Parameter for f := range params.Fields() { if !f.Exported() { @@ -366,13 +371,16 @@ func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { continue } - paramNames = append(paramNames, f.Name()) + parameters = append(parameters, Parameter{ + Name: f.Name(), + Type: f.Type(), + }) } suite.Tests = append(suite.Tests, SuiteTest{ Name: name, Parametrized: true, - Parameters: paramNames, + Parameters: parameters, }) if invalidParams { @@ -563,7 +571,7 @@ type Diagnostic struct { Issue Issue } -func (d Diagnostic) FormatJSON(w io.Writer, set *token.FileSet) { +func (d Diagnostic) JSON(w io.Writer, set *token.FileSet) { type Entry struct { File string `json:"file"` Line int `json:"line"` @@ -583,11 +591,11 @@ func (d Diagnostic) FormatJSON(w io.Writer, set *token.FileSet) { _ = json.NewEncoder(w).Encode(entry) } -func (d Diagnostic) Format(set *token.FileSet) string { +func (d Diagnostic) Print(w io.Writer, set *token.FileSet) { file := set.File(d.Pos) line := file.Line(d.Pos) - return fmt.Sprintf("%s:%d: %s", file.Name(), line, d.Issue.String()) + fmt.Fprintf(w, "%s:%d: %s\n", file.Name(), line, d.Issue.String()) } type Issue interface { @@ -683,27 +691,12 @@ type T struct { type SuiteTest struct { Name string Parametrized bool - Parameters []string -} - -func formatType(t types.Type) string { - switch t := t.(type) { - case *types.Named: - return formatNamedType(t) - - case *types.Pointer: - return "*" + formatType(t.Elem()) - - default: - return t.String() - } + Parameters []Parameter } -func formatNamedType(t *types.Named) string { - pkg := t.Obj().Pkg().Name() - name := t.Obj().Name() - - return pkg + "." + name +type Parameter struct { + Name string + Type types.Type } func unquote(s string) string { diff --git a/cmd/testo/internal/typeutil/util.go b/cmd/testo/internal/typeutil/util.go new file mode 100644 index 0000000..220c794 --- /dev/null +++ b/cmd/testo/internal/typeutil/util.go @@ -0,0 +1,23 @@ +package typeutil + +import "go/types" + +func Format(t types.Type) string { + switch t := t.(type) { + case *types.Named: + return formatNamedType(t) + + case *types.Pointer: + return "*" + Format(t.Elem()) + + default: + return t.String() + } +} + +func formatNamedType(t *types.Named) string { + pkg := t.Obj().Pkg().Name() + name := t.Obj().Name() + + return pkg + "." + name +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 1b329ab..932a1f2 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -17,6 +17,7 @@ import ( "github.com/ozontech/testo/cmd/testo/internal/cli" "github.com/ozontech/testo/cmd/testo/internal/loader" + "github.com/ozontech/testo/cmd/testo/internal/typeutil" ) func main() { @@ -100,10 +101,9 @@ func (cmd Lint) Run(patterns ...string) error { for _, d := range errLoad.Diagnostics { if cmd.JSON { - d.FormatJSON(&buf, errLoad.FSet) + d.JSON(&buf, errLoad.FSet) } else { - buf.WriteString(d.Format(errLoad.FSet)) - buf.WriteString("\n") + d.Print(&buf, errLoad.FSet) } } @@ -148,7 +148,7 @@ func (cmd Suites) Run(patterns ...string) error { symbol += "──" - fmt.Fprintf(w, " %s %s [P] %s\n", fallback, symbol, p) + fmt.Fprintf(w, " %s %s [P] %s (%s)\n", fallback, symbol, p.Name, typeutil.Format(p.Type)) } } else { fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) From 14f62da60269f6b4f1a5b4ae4b7a86a4bc7d66cb Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 2 Jul 2026 00:11:47 +0300 Subject: [PATCH 24/47] call gopls for references --- cmd/testo/internal/gopls/gopls.go | 85 ++++++++++++++++++++++ cmd/testo/internal/loader/loader.go | 105 ++-------------------------- cmd/testo/internal/loader/runner.go | 98 ++++++++++++++++++++++++++ cmd/testo/main.go | 12 +++- 4 files changed, 198 insertions(+), 102 deletions(-) create mode 100644 cmd/testo/internal/gopls/gopls.go create mode 100644 cmd/testo/internal/loader/runner.go diff --git a/cmd/testo/internal/gopls/gopls.go b/cmd/testo/internal/gopls/gopls.go new file mode 100644 index 0000000..3603445 --- /dev/null +++ b/cmd/testo/internal/gopls/gopls.go @@ -0,0 +1,85 @@ +package gopls + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +type Position struct { + File string + Line, Column int +} + +func (p Position) String() string { + return fmt.Sprintf("%s:%d:%d", p.File, p.Line, p.Column) +} + +type ReferencesOpts struct { + Position Position + Tags string +} + +func References(ctx context.Context, opts ReferencesOpts) ([]Position, error) { + cmd := exec.CommandContext(ctx, "gopls", "references", opts.Position.String()) + + cmd.Env = os.Environ() + + if opts.Tags != "" { + cmd.Env = append(cmd.Env, "GOFLAGS=-tags="+opts.Tags) + } + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("gopls: %w", err) + } + + var positions []Position + + for line := range bytes.Lines(out) { + pos, ok := parsePosition(string(line)) + if !ok { + continue + } + + positions = append(positions, pos) + } + + return positions, nil +} + +func parsePosition(s string) (Position, bool) { + const colon = ":" + + i := strings.LastIndex(s, colon) + if i == -1 { + return Position{}, false + } + + j := strings.LastIndex(s[:i], colon) + if j == -1 { + return Position{}, false + } + + columnStart, _, _ := strings.Cut(s[i+1:], "-") + + column, err := strconv.ParseInt(columnStart, 10, 32) + if err != nil { + return Position{}, false + } + + line, err := strconv.ParseInt(s[j+1:i], 10, 32) + if err != nil { + return Position{}, false + } + + return Position{ + File: s[:j], + Line: int(line), + Column: int(column), + }, true +} diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index ada0d67..490fb44 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -4,13 +4,9 @@ import ( "bytes" "encoding/json" "fmt" - "go/ast" - "go/format" - "go/parser" "go/token" "go/types" "io" - "path/filepath" "slices" "strings" @@ -70,7 +66,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { for _, name := range scope.Names() { obj := scope.Lookup(name) - suite, d, ok := cfg.asSuite(obj) + suite, d, ok := cfg.asSuite(fset, obj) if !ok { continue } @@ -88,8 +84,6 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { ) }) - cfg.fillSuiteRunners(pkgs, suites) - if len(diagnostics) > 0 { return suites, &LoadError{ FSet: fset, @@ -100,97 +94,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { return suites, nil } -func (c Config) filleSuiteRunnersFile( - pkg *packageslite.Package, - fset *token.FileSet, - file *ast.File, - suites []Suite, -) { - var importsTesto bool - - testoRunSuite := "testo.RunSuite" - - packageName := file.Name.Name - - for _, i := range file.Imports { - if unquote(i.Path.Value) == c.Testo { - importsTesto = true - - if i.Name != nil { - testoRunSuite = i.Name.Name + ".RunSuite" - } - - break - } - } - - if !importsTesto { - return - } - - testoRunSuite = strings.Trim(testoRunSuite, ".") - - for _, d := range file.Decls { - fd, ok := d.(*ast.FuncDecl) - if !ok { - continue - } - - if fd.Recv != nil || !parse.IsTest(fd.Name.Name, "Test") { - continue - } - - var buf bytes.Buffer - - format.Node(&buf, fset, fd) - - bufStr := buf.String() - - for i, s := range suites { - if !strings.Contains(bufStr, testoRunSuite) { - continue - } - - id := s.Name - if s.Package != packageName { - id = s.Package + "." + s.Name - } - - if strings.Contains(bufStr, id) { - suites[i].Runners = append(suites[i].Runners, SuiteRunner{ - Dir: pkg.Dir, - Name: fd.Name.Name, - }) - } - } - } -} - -func (c Config) fillSuiteRunners(pkgs []*packageslite.Package, suites []Suite) { - for _, pkg := range pkgs { - if !slices.Contains(pkg.TestImports, c.Testo) { - continue - } - - fset := token.NewFileSet() - - for _, f := range pkg.TestGoFiles { - file, err := parser.ParseFile( - fset, - filepath.Join(pkg.Dir, f), - nil, - parser.ParseComments, - ) - if err != nil { - continue - } - - c.filleSuiteRunnersFile(pkg, fset, file, suites) - } - } -} - -func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { +func (c Config) asSuite(fset *token.FileSet, obj types.Object) (Suite, []Diagnostic, bool) { named, ok := obj.Type().(*types.Named) if !ok { return Suite{}, nil, false @@ -218,6 +122,8 @@ func (c Config) asSuite(obj types.Object) (Suite, []Diagnostic, bool) { } suite := Suite{ + FSet: fset, + Pos: obj.Pos(), Package: obj.Pkg().Name(), Name: named.Obj().Name(), T: t, @@ -672,11 +578,12 @@ func (tm TestsMissing) String() string { func (TestsMissing) issue() {} type Suite struct { + FSet *token.FileSet + Pos token.Pos Package string Name string Tests []SuiteTest T T - Runners []SuiteRunner } type SuiteRunner struct { diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go new file mode 100644 index 0000000..a0baa46 --- /dev/null +++ b/cmd/testo/internal/loader/runner.go @@ -0,0 +1,98 @@ +package loader + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/gopls" +) + +func Runners(ctx context.Context, tags string, suite Suite) ([]SuiteRunner, error) { + f := suite.FSet.File(suite.Pos) + if f == nil { + return nil, fmt.Errorf("suite not found in file set") + } + + pos := f.Position(suite.Pos) + + refs, err := gopls.References(ctx, gopls.ReferencesOpts{ + Tags: tags, + Position: gopls.Position{ + File: f.Name(), + Line: pos.Line, + Column: pos.Column, + }, + }) + if err != nil { + return nil, fmt.Errorf("gopls references: %w", err) + } + + var runners []SuiteRunner + + for _, r := range refs { + runner, ok := asRunner(r, suite) + if !ok { + continue + } + + runners = append(runners, runner) + } + + return runners, nil +} + +func asRunner(ref gopls.Position, suite Suite) (SuiteRunner, bool) { + if !strings.HasSuffix(ref.File, "_test.go") { + return SuiteRunner{}, false + } + + file, err := os.Open(ref.File) + if err != nil { + return SuiteRunner{}, false + } + defer file.Close() + + s := bufio.NewScanner(file) + + var ( + i int + test string + ) + + for s.Scan() { + i++ + + line := s.Text() + + if strings.HasPrefix(line, "func Test") { + before, _, _ := strings.Cut(line, "(") + + test = strings.Fields(before)[1] + } + + if i != ref.Line { + continue + } + + if strings.Contains(line, "testo.RunSuite") { + return SuiteRunner{ + Dir: filepath.Dir(ref.File), + Name: test, + }, true + } + } + + err = s.Err() + if err != nil { + return SuiteRunner{}, false + } + + fmt.Printf("ref: %#+v\n", ref) + // TODO + + return SuiteRunner{}, false +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 932a1f2..38dfcb3 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "context" "errors" "flag" "fmt" @@ -179,7 +180,7 @@ type runMatched struct { } func (cmd Run) Run(patterns ...string) error { - id, extraFlags, err := cmd.parsePositional(patterns...) + id, extraFlags, err := cmd.parse(patterns...) if err != nil { return err } @@ -246,7 +247,12 @@ func (cmd Run) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, err tests := make(map[string]struct{}) for _, m := range matched { - for _, r := range m.Suite.Runners { + runners, err := loader.Runners(context.Background(), cmd.Load.Tags, m.Suite) + if err != nil { + return nil, fmt.Errorf("find runners for suite %q: %w", m.Suite.Name, err) + } + + for _, r := range runners { packages[r.Dir] = struct{}{} suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} } @@ -308,7 +314,7 @@ func (cmd Run) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, err return c, nil } -func (cmd Run) parsePositional(args ...string) (id *runID, extra []string, err error) { +func (cmd Run) parse(args ...string) (id *runID, extra []string, err error) { for i, p := range args { if strings.HasPrefix(p, "-") { extra = append(extra, args[i:]...) From 03c7245f0a3bc1eaa550dcdca8a150d8ab0ef928 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 4 Jul 2026 10:59:55 +0300 Subject: [PATCH 25/47] minor improvements --- cmd/testo/internal/loader/loader.go | 5 +++++ cmd/testo/internal/loader/runner.go | 1 - cmd/testo/main.go | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 490fb44..9db2c1e 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -2,6 +2,7 @@ package loader import ( "bytes" + "cmp" "encoding/json" "fmt" "go/token" @@ -84,6 +85,10 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { ) }) + slices.SortFunc(suites, func(a, b Suite) int { + return cmp.Compare(a.Name, b.Name) + }) + if len(diagnostics) > 0 { return suites, &LoadError{ FSet: fset, diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go index a0baa46..6d6b5e9 100644 --- a/cmd/testo/internal/loader/runner.go +++ b/cmd/testo/internal/loader/runner.go @@ -91,7 +91,6 @@ func asRunner(ref gopls.Position, suite Suite) (SuiteRunner, bool) { return SuiteRunner{}, false } - fmt.Printf("ref: %#+v\n", ref) // TODO return SuiteRunner{}, false diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 38dfcb3..af346be 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -56,6 +56,7 @@ Pattern: cli.Add("suites", func(f *flag.FlagSet, cmd *Suites) { f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") + f.BoolVar(&cmd.One, "1", false, "show one suite per line") }, cli.WithShort("Show testo suites")) cli.Add( @@ -113,6 +114,7 @@ func (cmd Lint) Run(patterns ...string) error { type Suites struct { Load loader.Config + One bool } func (cmd Suites) Run(patterns ...string) error { @@ -121,6 +123,14 @@ func (cmd Suites) Run(patterns ...string) error { return err } + if cmd.One { + for _, s := range suites { + fmt.Println(s.Name) + } + + return nil + } + for i, s := range suites { w := bufio.NewWriter(os.Stdout) From 210329fcddc234acb60f0e22efdbeed88d3e1918 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 4 Jul 2026 12:59:21 +0300 Subject: [PATCH 26/47] improvements --- cmd/testo/internal/loader/loader.go | 8 ++++---- cmd/testo/internal/packageslite/packages.go | 6 +++++- cmd/testo/main.go | 11 +++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 9db2c1e..6603fff 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -67,7 +67,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { for _, name := range scope.Names() { obj := scope.Lookup(name) - suite, d, ok := cfg.asSuite(fset, obj) + suite, d, ok := cfg.asSuite(fset, pkg, obj) if !ok { continue } @@ -99,7 +99,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { return suites, nil } -func (c Config) asSuite(fset *token.FileSet, obj types.Object) (Suite, []Diagnostic, bool) { +func (c Config) asSuite(fset *token.FileSet, pkg *packageslite.Package, obj types.Object) (Suite, []Diagnostic, bool) { named, ok := obj.Type().(*types.Named) if !ok { return Suite{}, nil, false @@ -129,7 +129,7 @@ func (c Config) asSuite(fset *token.FileSet, obj types.Object) (Suite, []Diagnos suite := Suite{ FSet: fset, Pos: obj.Pos(), - Package: obj.Pkg().Name(), + Package: pkg, Name: named.Obj().Name(), T: t, } @@ -585,7 +585,7 @@ func (TestsMissing) issue() {} type Suite struct { FSet *token.FileSet Pos token.Pos - Package string + Package *packageslite.Package Name string Tests []SuiteTest T T diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index 2ae4fe7..3329229 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -31,17 +31,21 @@ type Package struct { Dir string TestImports []string TestGoFiles []string + Info types.Info depOnly bool } func (p *Package) Init(fset *token.FileSet, conf *types.Config) error { - checked, err := conf.Check(p.Path, fset, p.Syntax, nil) + var info types.Info + + checked, err := conf.Check(p.Path, fset, p.Syntax, &info) if err != nil { return err } p.Types = checked + p.Info = info return nil } diff --git a/cmd/testo/main.go b/cmd/testo/main.go index af346be..56a25c4 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -57,7 +57,10 @@ Pattern: f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.BoolVar(&cmd.One, "1", false, "show one suite per line") - }, cli.WithShort("Show testo suites")) + }, + cli.WithShort("Show testo suites"), + cli.WithUsage(`[flags] [pattern...] [flags]`), + ) cli.Add( "version", @@ -210,7 +213,7 @@ func (cmd Run) Run(patterns ...string) error { continue } - key := s.Package + "." + s.Name + key := s.Package.Name + "." + s.Name if m, ok := matched[key]; ok { maps.Copy(m.Tests, tests) @@ -223,7 +226,7 @@ func (cmd Run) Run(patterns ...string) error { } } else { for _, s := range suites { - key := s.Package + "." + s.Name + key := s.Package.Name + "." + s.Name matched[key] = runMatched{Suite: s} } @@ -416,7 +419,7 @@ type runID struct { } func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { - if id.Package != nil && !id.Package.MatchString(suite.Package) { + if id.Package != nil && !id.Package.MatchString(suite.Package.Name) { return nil, false } From 40405e9e5f94c2b2c92866569fe0a6feeb3c5065 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 4 Jul 2026 13:42:47 +0300 Subject: [PATCH 27/47] do not rely on gopls --- cmd/testo/internal/gopls/gopls.go | 85 ----------- cmd/testo/internal/loader/loader.go | 21 ++- cmd/testo/internal/loader/runner.go | 147 +++++++++++--------- cmd/testo/internal/packageslite/packages.go | 7 +- cmd/testo/main.go | 2 +- 5 files changed, 102 insertions(+), 160 deletions(-) delete mode 100644 cmd/testo/internal/gopls/gopls.go diff --git a/cmd/testo/internal/gopls/gopls.go b/cmd/testo/internal/gopls/gopls.go deleted file mode 100644 index 3603445..0000000 --- a/cmd/testo/internal/gopls/gopls.go +++ /dev/null @@ -1,85 +0,0 @@ -package gopls - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "strconv" - "strings" -) - -type Position struct { - File string - Line, Column int -} - -func (p Position) String() string { - return fmt.Sprintf("%s:%d:%d", p.File, p.Line, p.Column) -} - -type ReferencesOpts struct { - Position Position - Tags string -} - -func References(ctx context.Context, opts ReferencesOpts) ([]Position, error) { - cmd := exec.CommandContext(ctx, "gopls", "references", opts.Position.String()) - - cmd.Env = os.Environ() - - if opts.Tags != "" { - cmd.Env = append(cmd.Env, "GOFLAGS=-tags="+opts.Tags) - } - - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("gopls: %w", err) - } - - var positions []Position - - for line := range bytes.Lines(out) { - pos, ok := parsePosition(string(line)) - if !ok { - continue - } - - positions = append(positions, pos) - } - - return positions, nil -} - -func parsePosition(s string) (Position, bool) { - const colon = ":" - - i := strings.LastIndex(s, colon) - if i == -1 { - return Position{}, false - } - - j := strings.LastIndex(s[:i], colon) - if j == -1 { - return Position{}, false - } - - columnStart, _, _ := strings.Cut(s[i+1:], "-") - - column, err := strconv.ParseInt(columnStart, 10, 32) - if err != nil { - return Position{}, false - } - - line, err := strconv.ParseInt(s[j+1:i], 10, 32) - if err != nil { - return Position{}, false - } - - return Position{ - File: s[:j], - Line: int(line), - Column: int(column), - }, true -} diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 6603fff..099a480 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -3,6 +3,7 @@ package loader import ( "bytes" "cmp" + "context" "encoding/json" "fmt" "go/token" @@ -67,7 +68,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { for _, name := range scope.Names() { obj := scope.Lookup(name) - suite, d, ok := cfg.asSuite(fset, pkg, obj) + suite, d, ok := cfg.asSuite(fset, pkg, pkgs, obj) if !ok { continue } @@ -99,7 +100,12 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { return suites, nil } -func (c Config) asSuite(fset *token.FileSet, pkg *packageslite.Package, obj types.Object) (Suite, []Diagnostic, bool) { +func (c Config) asSuite( + fset *token.FileSet, + pkg *packageslite.Package, + pkgs []*packageslite.Package, + obj types.Object, +) (Suite, []Diagnostic, bool) { named, ok := obj.Type().(*types.Named) if !ok { return Suite{}, nil, false @@ -132,6 +138,11 @@ func (c Config) asSuite(fset *token.FileSet, pkg *packageslite.Package, obj type Package: pkg, Name: named.Obj().Name(), T: t, + Type: obj.Type(), + } + + suite.Runners = func(ctx context.Context) ([]SuiteRunner, error) { + return c.loadRunners(ctx, fset, suite, pkgs) } cases, diagnostics, fatal := c.collectCases(named) @@ -589,6 +600,8 @@ type Suite struct { Name string Tests []SuiteTest T T + Runners func(ctx context.Context) ([]SuiteRunner, error) + Type types.Type } type SuiteRunner struct { @@ -610,7 +623,3 @@ type Parameter struct { Name string Type types.Type } - -func unquote(s string) string { - return strings.Trim(s, `"'`) -} diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go index 6d6b5e9..8874cfd 100644 --- a/cmd/testo/internal/loader/runner.go +++ b/cmd/testo/internal/loader/runner.go @@ -1,97 +1,112 @@ package loader import ( - "bufio" "context" - "fmt" - "os" + "go/ast" + "go/token" + "go/types" "path/filepath" "strings" - "github.com/ozontech/testo/cmd/testo/internal/gopls" + "github.com/ozontech/testo/cmd/testo/internal/packageslite" + "github.com/ozontech/testo/internal/parse" ) -func Runners(ctx context.Context, tags string, suite Suite) ([]SuiteRunner, error) { - f := suite.FSet.File(suite.Pos) - if f == nil { - return nil, fmt.Errorf("suite not found in file set") - } +func (c *Config) loadRunners( + ctx context.Context, + fset *token.FileSet, + suite Suite, + pkgs []*packageslite.Package, +) ([]SuiteRunner, error) { + var runners []SuiteRunner - pos := f.Position(suite.Pos) - - refs, err := gopls.References(ctx, gopls.ReferencesOpts{ - Tags: tags, - Position: gopls.Position{ - File: f.Name(), - Line: pos.Line, - Column: pos.Column, - }, - }) - if err != nil { - return nil, fmt.Errorf("gopls references: %w", err) - } + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + tokenFile := fset.File(file.Pos()) - var runners []SuiteRunner + if !strings.HasSuffix(tokenFile.Name(), "_test.go") { + continue + } - for _, r := range refs { - runner, ok := asRunner(r, suite) - if !ok { - continue - } + var testName string - runners = append(runners, runner) - } + ast.Inspect(file, func(n ast.Node) bool { + if f, ok := n.(*ast.FuncDecl); ok { + if parse.IsTest(f.Name.Name, "Test") { + testName = f.Name.Name + } - return runners, nil -} + return true + } -func asRunner(ref gopls.Position, suite Suite) (SuiteRunner, bool) { - if !strings.HasSuffix(ref.File, "_test.go") { - return SuiteRunner{}, false - } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } - file, err := os.Open(ref.File) - if err != nil { - return SuiteRunner{}, false - } - defer file.Close() + fun := pkg.Info.Uses[funcIdent(call)] + if fun == nil { + sel, ok := call.Fun.(*ast.SelectorExpr) + if ok { + fun = pkg.Info.Uses[sel.Sel] + } + } - s := bufio.NewScanner(file) + if fun == nil { + return true + } - var ( - i int - test string - ) + fn, ok := fun.(*types.Func) + if !ok { + return true + } - for s.Scan() { - i++ + if fn.Pkg() == nil || fn.Pkg().Path() != c.Testo || fn.Name() != "RunSuite" { + return true + } - line := s.Text() + sig := pkg.Info.Types[call.Fun].Type.(*types.Signature) - if strings.HasPrefix(line, "func Test") { - before, _, _ := strings.Cut(line, "(") + params := sig.Params() - test = strings.Fields(before)[1] - } + if params.Len() == 0 { + return true + } - if i != ref.Line { - continue - } + identical := types.Identical(elem(params.At(1).Type()), elem(suite.Type)) + + if identical { + runners = append(runners, SuiteRunner{ + Name: testName, + Dir: filepath.Dir(tokenFile.Name()), + }) + } - if strings.Contains(line, "testo.RunSuite") { - return SuiteRunner{ - Dir: filepath.Dir(ref.File), - Name: test, - }, true + return true + }) } } - err = s.Err() - if err != nil { - return SuiteRunner{}, false + return runners, nil +} + +func funcIdent(call *ast.CallExpr) *ast.Ident { + switch f := call.Fun.(type) { + case *ast.Ident: + return f + + case *ast.SelectorExpr: + return f.Sel + + default: + return nil } +} - // TODO +func elem(a types.Type) types.Type { + if ptr, ok := a.(*types.Pointer); ok { + return elem(ptr.Elem()) + } - return SuiteRunner{}, false + return a } diff --git a/cmd/testo/internal/packageslite/packages.go b/cmd/testo/internal/packageslite/packages.go index 3329229..ef2b2bf 100644 --- a/cmd/testo/internal/packageslite/packages.go +++ b/cmd/testo/internal/packageslite/packages.go @@ -37,7 +37,10 @@ type Package struct { } func (p *Package) Init(fset *token.FileSet, conf *types.Config) error { - var info types.Info + info := types.Info{ + Uses: make(map[*ast.Ident]types.Object), + Types: make(map[ast.Expr]types.TypeAndValue), + } checked, err := conf.Check(p.Path, fset, p.Syntax, &info) if err != nil { @@ -73,7 +76,7 @@ func Load(config Config, patterns ...string) ([]*Package, error) { var importMap map[string]string conf := types.Config{ - IgnoreFuncBodies: true, + // IgnoreFuncBodies: true, DisableUnusedImportCheck: true, FakeImportC: true, Importer: importerFunc(func(path string) (*types.Package, error) { diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 56a25c4..bc0126e 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -260,7 +260,7 @@ func (cmd Run) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, err tests := make(map[string]struct{}) for _, m := range matched { - runners, err := loader.Runners(context.Background(), cmd.Load.Tags, m.Suite) + runners, err := m.Suite.Runners(context.Background()) if err != nil { return nil, fmt.Errorf("find runners for suite %q: %w", m.Suite.Name, err) } From 200eb921a5c460716643fb9a2938cae420d82677 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 00:53:08 +0300 Subject: [PATCH 28/47] cache runners --- cmd/testo/internal/loader/loader.go | 8 +++--- cmd/testo/internal/loader/runner.go | 41 +++++++++++++++++++++++++---- cmd/testo/main.go | 9 ++++++- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 099a480..0b1602c 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -40,6 +40,8 @@ type Config struct { Tags string Testo string Strict bool + + runners map[types.Type]map[SuiteRunner]struct{} } func Load(cfg Config, patterns ...string) ([]Suite, error) { @@ -100,7 +102,7 @@ func Load(cfg Config, patterns ...string) ([]Suite, error) { return suites, nil } -func (c Config) asSuite( +func (c *Config) asSuite( fset *token.FileSet, pkg *packageslite.Package, pkgs []*packageslite.Package, @@ -333,7 +335,7 @@ func (c Config) asSuite( return suite, diagnostics, true } -func (c Config) asT(f *types.Var) (T, bool) { +func (c *Config) asT(f *types.Var) (T, bool) { if !f.Embedded() { return T{}, false } @@ -368,7 +370,7 @@ type Case struct { Type types.Type } -func (c Config) collectCases( +func (c *Config) collectCases( suite *types.Named, ) (cases Cases, diagnostics []Diagnostic, fatal bool) { cases = make(Cases) diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go index 8874cfd..d371919 100644 --- a/cmd/testo/internal/loader/runner.go +++ b/cmd/testo/internal/loader/runner.go @@ -5,7 +5,9 @@ import ( "go/ast" "go/token" "go/types" + "maps" "path/filepath" + "slices" "strings" "github.com/ozontech/testo/cmd/testo/internal/packageslite" @@ -18,6 +20,14 @@ func (c *Config) loadRunners( suite Suite, pkgs []*packageslite.Package, ) ([]SuiteRunner, error) { + if c.runners == nil { + c.runners = make(map[types.Type]map[SuiteRunner]struct{}) + } + + if runners, ok := c.runners[suite.Type]; ok { + return slices.Collect(maps.Keys(runners)), nil + } + var runners []SuiteRunner for _, pkg := range pkgs { @@ -73,13 +83,22 @@ func (c *Config) loadRunners( return true } - identical := types.Identical(elem(params.At(1).Type()), elem(suite.Type)) + suiteType := elem(params.At(1).Type()) + + identical := types.Identical( + suiteType, + elem(suite.Type), + ) + + r := SuiteRunner{ + Name: testName, + Dir: filepath.Dir(tokenFile.Name()), + } if identical { - runners = append(runners, SuiteRunner{ - Name: testName, - Dir: filepath.Dir(tokenFile.Name()), - }) + runners = append(runners, r) + } else { + c.addRunner(suiteType, r) } return true @@ -90,6 +109,18 @@ func (c *Config) loadRunners( return runners, nil } +func (c *Config) addRunner(suite types.Type, runner SuiteRunner) { + if _, ok := c.runners[suite]; ok { + c.runners[suite][runner] = struct{}{} + + return + } + + c.runners[suite] = map[SuiteRunner]struct{}{ + runner: {}, + } +} + func funcIdent(call *ast.CallExpr) *ast.Ident { switch f := call.Fun.(type) { case *ast.Ident: diff --git a/cmd/testo/main.go b/cmd/testo/main.go index bc0126e..3124a4b 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -162,7 +162,14 @@ func (cmd Suites) Run(patterns ...string) error { symbol += "──" - fmt.Fprintf(w, " %s %s [P] %s (%s)\n", fallback, symbol, p.Name, typeutil.Format(p.Type)) + fmt.Fprintf( + w, + " %s %s [P] %s (%s)\n", + fallback, + symbol, + p.Name, + typeutil.Format(p.Type), + ) } } else { fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) From fd7bba335bc725423a53bbd26491cb0627733a5f Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 01:20:06 +0300 Subject: [PATCH 29/47] better suites command flags --- cmd/testo/internal/cli/flag.go | 40 ++++++++++++++++++ cmd/testo/main.go | 76 +++++++++++----------------------- 2 files changed, 64 insertions(+), 52 deletions(-) create mode 100644 cmd/testo/internal/cli/flag.go diff --git a/cmd/testo/internal/cli/flag.go b/cmd/testo/internal/cli/flag.go new file mode 100644 index 0000000..53265b8 --- /dev/null +++ b/cmd/testo/internal/cli/flag.go @@ -0,0 +1,40 @@ +package cli + +import ( + "bytes" + "flag" + "text/template" +) + +var _ flag.Value = (*FlagTemplate)(nil) + +type FlagTemplate struct { + template *template.Template + source string +} + +func (f *FlagTemplate) Execute(v any) (string, error) { + var buf bytes.Buffer + + if err := f.template.Execute(&buf, v); err != nil { + return "", err + } + + return buf.String(), nil +} + +func (f *FlagTemplate) Set(s string) error { + parsed, err := template.New("flag").Parse(s) + if err != nil { + return err + } + + f.template = parsed + f.source = s + + return nil +} + +func (f *FlagTemplate) String() string { + return f.source +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 3124a4b..7afddb9 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "bytes" "context" "errors" @@ -18,7 +17,6 @@ import ( "github.com/ozontech/testo/cmd/testo/internal/cli" "github.com/ozontech/testo/cmd/testo/internal/loader" - "github.com/ozontech/testo/cmd/testo/internal/typeutil" ) func main() { @@ -54,9 +52,11 @@ Pattern: ) cli.Add("suites", func(f *flag.FlagSet, cmd *Suites) { + cmd.Format.Set("{{ .Suite }}") + f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.BoolVar(&cmd.One, "1", false, "show one suite per line") + f.Var(&cmd.Format, "f", "output format") }, cli.WithShort("Show testo suites"), cli.WithUsage(`[flags] [pattern...] [flags]`), @@ -116,8 +116,8 @@ func (cmd Lint) Run(patterns ...string) error { } type Suites struct { - Load loader.Config - One bool + Load loader.Config + Format cli.FlagTemplate } func (cmd Suites) Run(patterns ...string) error { @@ -126,61 +126,33 @@ func (cmd Suites) Run(patterns ...string) error { return err } - if cmd.One { - for _, s := range suites { - fmt.Println(s.Name) - } + seen := make(map[string]bool) - return nil - } - - for i, s := range suites { - w := bufio.NewWriter(os.Stdout) - - fmt.Fprintln(w, "[S] "+s.Name) - - for i, t := range s.Tests { - symbol := "└" - fallback := " " - - if i != len(s.Tests)-1 { - symbol = "├" - fallback = "│" + for _, s := range suites { + for _, t := range s.Tests { + var data struct { + Suite string + Test struct { + Name string + } } - symbol += "──" + data.Suite = s.Name + data.Test.Name = t.Name - if t.Parametrized { - fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) - - for j, p := range t.Parameters { - symbol := "└" - - if j != len(t.Parameters)-1 { - symbol = "├" - } - - symbol += "──" + line, err := cmd.Format.Execute(data) + if err != nil { + return err + } - fmt.Fprintf( - w, - " %s %s [P] %s (%s)\n", - fallback, - symbol, - p.Name, - typeutil.Format(p.Type), - ) - } - } else { - fmt.Fprintf(w, " %s [T] %s\n", symbol, t.Name) + if seen[line] { + continue } - } - if i != len(suites)-1 { - fmt.Fprintln(w) - } + seen[line] = true - w.Flush() + fmt.Println(line) + } } return nil From 1aa7b4ef59150e1bb5364ed562f19424b19c1910 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 01:40:25 +0300 Subject: [PATCH 30/47] improve format options --- cmd/testo/main.go | 63 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 7afddb9..f205d37 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -129,33 +129,58 @@ func (cmd Suites) Run(patterns ...string) error { seen := make(map[string]bool) for _, s := range suites { - for _, t := range s.Tests { - var data struct { - Suite string - Test struct { - Name string - } - } + lines, err := cmd.showSuite(s, seen) + if err != nil { + return err + } - data.Suite = s.Name - data.Test.Name = t.Name + for _, l := range lines { + fmt.Println(l) + } + } - line, err := cmd.Format.Execute(data) - if err != nil { - return err - } + return nil +} - if seen[line] { - continue - } +func (cmd Suites) showSuite(suite loader.Suite, seen map[string]bool) ([]string, error) { + type Test struct { + Name string + } - seen[line] = true + type Data struct { + Suite string + Test string + Tests []string + } - fmt.Println(line) + data := Data{ + Suite: suite.Name, + } + + for _, t := range suite.Tests { + data.Tests = append(data.Tests, t.Name) + } + + var lines []string + + for _, t := range suite.Tests { + data.Test = t.Name + + line, err := cmd.Format.Execute(data) + if err != nil { + return nil, err + } + + if seen[line] { + continue } + + seen[line] = true + + lines = append(lines, line) } - return nil + return lines, nil } type Run struct { From de21ac8f24c81ffad5732f5fd0c6977928417ecc Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 13:42:19 +0300 Subject: [PATCH 31/47] improve format output --- cmd/testo/internal/loader/loader.go | 3 + cmd/testo/main.go | 103 +++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 0b1602c..d85720d 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -205,6 +205,7 @@ func (c *Config) asSuite( suite.Tests = append(suite.Tests, SuiteTest{ Name: name, + Pos: m.Pos(), }) case 2: @@ -302,6 +303,7 @@ func (c *Config) asSuite( } suite.Tests = append(suite.Tests, SuiteTest{ + Pos: m.Pos(), Name: name, Parametrized: true, Parameters: parameters, @@ -619,6 +621,7 @@ type SuiteTest struct { Name string Parametrized bool Parameters []Parameter + Pos token.Pos } type Parameter struct { diff --git a/cmd/testo/main.go b/cmd/testo/main.go index f205d37..b06be90 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -1,14 +1,17 @@ package main import ( + "bufio" "bytes" "context" "errors" "flag" "fmt" + "go/token" "maps" "os" "os/exec" + "path/filepath" "regexp" "runtime" "runtime/debug" @@ -52,14 +55,44 @@ Pattern: ) cli.Add("suites", func(f *flag.FlagSet, cmd *Suites) { - cmd.Format.Set("{{ .Suite }}") + cmd.Format.Set("{{ .Suite.Name }}") f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") f.Var(&cmd.Format, "f", "output format") + f.BoolVar(&cmd.Nul, "0", false, "output each line delimited by NUL byte") }, cli.WithShort("Show testo suites"), - cli.WithUsage(`[flags] [pattern...] [flags]`), + cli.WithUsage(`[flags] [pattern...] [flags] + +Format: + flag -f accepts Go text/template string with the following data as input: + + type Data struct { + Package string + Suite Entity + Test Entity + Tests []Entity + } + + type Entity struct { + Name string + Pos Pos + } + + type Pos struct { + Dir string + Filename string + Path string + Line int + Column int + } + +Examples: + pick suite test with fzf and bat preview + + testo suites ./... -0 -f '{{ .Package }}.{{ .Suite.Name }}.{{ .Test.Name }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up +`), ) cli.Add( @@ -118,6 +151,7 @@ func (cmd Lint) Run(patterns ...string) error { type Suites struct { Load loader.Config Format cli.FlagTemplate + Nul bool } func (cmd Suites) Run(patterns ...string) error { @@ -129,46 +163,73 @@ func (cmd Suites) Run(patterns ...string) error { seen := make(map[string]bool) for _, s := range suites { - lines, err := cmd.showSuite(s, seen) + err := cmd.printSuite(s, seen) if err != nil { return err } - - for _, l := range lines { - fmt.Println(l) - } } return nil } -func (cmd Suites) showSuite(suite loader.Suite, seen map[string]bool) ([]string, error) { - type Test struct { +func (cmd Suites) printSuite(suite loader.Suite, seen map[string]bool) error { + type Pos struct { + Dir string + Filename string + Path string + Line int + Column int + } + + newPos := func(p token.Position) Pos { + return Pos{ + Dir: filepath.Dir(p.Filename), + Filename: filepath.Base(p.Filename), + Path: p.Filename, + Line: p.Line, + Column: p.Column, + } + } + + type Entity struct { Name string + Pos Pos } type Data struct { - Suite string - Test string - Tests []string + Package string + Suite Entity + Test Entity + Tests []Entity } data := Data{ - Suite: suite.Name, + Package: suite.Package.Name, + Suite: Entity{ + Name: suite.Name, + Pos: newPos(suite.FSet.Position(suite.Pos)), + }, } for _, t := range suite.Tests { - data.Tests = append(data.Tests, t.Name) + data.Tests = append(data.Tests, Entity{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + }) } - var lines []string + w := bufio.NewWriter(os.Stdout) + defer w.Flush() for _, t := range suite.Tests { - data.Test = t.Name + data.Test = Entity{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + } line, err := cmd.Format.Execute(data) if err != nil { - return nil, err + return err } if seen[line] { @@ -177,10 +238,14 @@ func (cmd Suites) showSuite(suite loader.Suite, seen map[string]bool) ([]string, seen[line] = true - lines = append(lines, line) + if cmd.Nul { + fmt.Fprint(w, line, "\x00") + } else { + fmt.Fprintln(w, line) + } } - return lines, nil + return nil } type Run struct { From bc3429a9d6e5c5fa34d68092d71ad3417bf667ec Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 13:55:52 +0300 Subject: [PATCH 32/47] refactor cmd packages --- cmd/testo/internal/cmd/cmd.go | 6 + cmd/testo/internal/cmd/cmdlint/lint.go | 50 ++ cmd/testo/internal/cmd/cmdrun/run.go | 301 +++++++++++ cmd/testo/internal/cmd/cmdsuites/suites.go | 160 ++++++ cmd/testo/internal/cmd/cmdversion/version.go | 34 ++ cmd/testo/main.go | 508 +------------------ 6 files changed, 555 insertions(+), 504 deletions(-) create mode 100644 cmd/testo/internal/cmd/cmd.go create mode 100644 cmd/testo/internal/cmd/cmdlint/lint.go create mode 100644 cmd/testo/internal/cmd/cmdrun/run.go create mode 100644 cmd/testo/internal/cmd/cmdsuites/suites.go create mode 100644 cmd/testo/internal/cmd/cmdversion/version.go diff --git a/cmd/testo/internal/cmd/cmd.go b/cmd/testo/internal/cmd/cmd.go new file mode 100644 index 0000000..4a4195f --- /dev/null +++ b/cmd/testo/internal/cmd/cmd.go @@ -0,0 +1,6 @@ +package cmd + +const ( + DefaultTags = "example,e2e,integration,functional,smoke" + DefaultTesto = "github.com/ozontech/testo" +) diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go new file mode 100644 index 0000000..58d0982 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -0,0 +1,50 @@ +package cmdlint + +import ( + "bytes" + "errors" + "flag" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("lint", func(f *flag.FlagSet, c *Cmd) { + f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.BoolVar(&c.Load.Strict, "strict", false, "enable strict mode") + f.BoolVar(&c.JSON, "json", false, "output json") + }, cli.WithShort("Run testo linter")) +} + +type Cmd struct { + Load loader.Config + JSON bool +} + +func (c Cmd) Run(patterns ...string) error { + _, err := loader.Load(c.Load, patterns...) + if err == nil { + return nil + } + + var errLoad *loader.LoadError + + if !errors.As(err, &errLoad) { + return err + } + + var buf bytes.Buffer + + for _, d := range errLoad.Diagnostics { + if c.JSON { + d.JSON(&buf, errLoad.FSet) + } else { + d.Print(&buf, errLoad.FSet) + } + } + + return cli.Exit(1).Stdout(buf.String()) +} diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go new file mode 100644 index 0000000..1774e6e --- /dev/null +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -0,0 +1,301 @@ +package cmdrun + +import ( + "context" + "errors" + "flag" + "fmt" + "maps" + "os" + "os/exec" + "regexp" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("run", func(f *flag.FlagSet, c *Cmd) { + f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.StringVar(&c.Sep, "s", ".", "pattern separator") + f.BoolVar(&c.N, "n", false, "print the commands but do not run them") + f.BoolVar(&c.Verbose, "v", false, "verbose output") + f.BoolVar(&c.JSON, "json", false, "log verbose output and test results in JSON") + }, + cli.WithShort("Run testo suites"), + cli.WithUsage(`[flags] [pattern] [flags] -- [test flags] + +Pattern: + suite suite regex + suite.test suite and test regex + package.suite.test package, suite and test regex`), + ) +} + +type Cmd struct { + Load loader.Config + Sep string + N bool + Verbose bool + JSON bool +} + +type runMatched struct { + Suite loader.Suite + Tests map[string]struct{} +} + +func (c Cmd) Run(patterns ...string) error { + id, extraFlags, err := c.parse(patterns...) + if err != nil { + return err + } + + suites, err := loader.Load(c.Load, "./...") + if err != nil { + return err + } + + matched := make(map[string]runMatched) + + if id != nil { + for _, s := range suites { + tests, ok := id.match(s) + + if !ok { + continue + } + + key := s.Package.Name + "." + s.Name + + if m, ok := matched[key]; ok { + maps.Copy(m.Tests, tests) + } else { + matched[key] = runMatched{ + Suite: s, + Tests: tests, + } + } + } + } else { + for _, s := range suites { + key := s.Package.Name + "." + s.Name + + matched[key] = runMatched{Suite: s} + } + } + + if len(matched) == 0 { + if id != nil { + return fmt.Errorf("%q did not match any suites", id.Source) + } + + return errors.New("testo suites not found") + } + + res, err := c.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) + if err != nil { + return fmt.Errorf("failed to build go test command: %w", err) + } + + if c.N { + fmt.Println(res.String()) + + return nil + } + + return res.Run() +} + +func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { + packages := make(map[string]struct{}) + suiteCallers := make(map[string]struct{}) + tests := make(map[string]struct{}) + + for _, m := range matched { + runners, err := m.Suite.Runners(context.Background()) + if err != nil { + return nil, fmt.Errorf("find runners for suite %q: %w", m.Suite.Name, err) + } + + for _, r := range runners { + packages[r.Dir] = struct{}{} + suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} + } + + for t := range m.Tests { + tests[t] = struct{}{} + } + } + + if len(matched) > 0 && len(packages) == 0 { + return nil, errors.New("suite callers not found") + } + + args := []string{"test", "-tags", c.Load.Tags} + + if c.Verbose { + args = append(args, "-v") + } + + if c.JSON { + args = append(args, "-json") + } + + for p := range packages { + args = append(args, p) + } + + if len(packages) == 0 { + args = append(args, ".") + } + + if len(suiteCallers) > 0 { + args = append( + args, + "-run", + strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), + ) + } + + if len(tests) > 0 { + args = append( + args, + "-testo.m", + fmt.Sprintf( + "^(%s)$", + strings.Join(slices.Sorted(maps.Keys(tests)), "|"), + ), + ) + } + + args = append(args, extra...) + + command := exec.Command("go", args...) + + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Env = os.Environ() + + return command, nil +} + +func (c Cmd) parse(args ...string) (id *runID, extra []string, err error) { + for i, p := range args { + if strings.HasPrefix(p, "-") { + extra = append(extra, args[i:]...) + + break + } + + if id != nil { + return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) + } + + parsed, err := c.id(p) + if err != nil { + return nil, nil, fmt.Errorf("parse %q: %w", p, err) + } + + id = &parsed + } + + return id, extra, nil +} + +func (c Cmd) id(pattern string) (runID, error) { + fields := strings.Split(pattern, c.Sep) + + switch len(fields) { + case 1: // suite + suite, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + return runID{ + Suite: suite, + Source: pattern, + }, nil + + case 2: // suite.test + suite, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + test, err := regexp.Compile(fields[1]) + if err != nil { + return runID{}, err + } + + return runID{ + Suite: suite, + Test: test, + Source: pattern, + }, nil + + case 3: // package.suite.test + pkg, err := regexp.Compile(fields[0]) + if err != nil { + return runID{}, err + } + + suite, err := regexp.Compile(fields[1]) + if err != nil { + return runID{}, err + } + + test, err := regexp.Compile(fields[2]) + if err != nil { + return runID{}, err + } + + return runID{ + Package: pkg, + Suite: suite, + Test: test, + Source: pattern, + }, nil + + default: + return runID{}, errors.New("invalid syntax") + } +} + +type runID struct { + Package *regexp.Regexp + Suite *regexp.Regexp + Test *regexp.Regexp + + Source string +} + +func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { + if id.Package != nil && !id.Package.MatchString(suite.Package.Name) { + return nil, false + } + + if id.Suite != nil && !id.Suite.MatchString(suite.Name) { + return nil, false + } + + if id.Test == nil { + return nil, true + } + + tests = make(map[string]struct{}) + + for _, t := range suite.Tests { + if id.Test.MatchString(t.Name) { + tests[t.Name] = struct{}{} + ok = true + } + } + + return tests, ok +} diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go new file mode 100644 index 0000000..3d839a1 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -0,0 +1,160 @@ +package cmdsuites + +import ( + "flag" + "fmt" + "go/token" + "path/filepath" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/cmd" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("suites", func(f *flag.FlagSet, c *Cmd) { + c.Format.Set("{{ .Suite }}") + + f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") + f.Var(&c.Format, "f", "output format") + f.BoolVar(&c.Nul, "0", false, "output each line delimited by NUL byte") + }, + cli.WithShort("Show testo suites"), + cli.WithUsage(`[flags] [pattern...] [flags] + +Format: + flag -f accepts Go text/template string with the following data as input: + + type Data struct { + Package string + Suite Entity + Test Entity + Tests []Entity + } + + type Entity struct { + Name string + Pos Pos + } + + type Pos struct { + Dir string + Filename string + Path string + Line int + Column int + } + +Examples: + pick suite test with fzf and bat preview + + testo suites ./... -0 -f '{{ .Package }}.{{ .Suite.Name }}.{{ .Test.Name }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up +`), + ) +} + +type Cmd struct { + Load loader.Config + Format cli.FlagTemplate + Nul bool +} + +func (c Cmd) Run(patterns ...string) error { + suites, err := loader.Load(c.Load, patterns...) + if err != nil { + return err + } + + seen := make(map[string]bool) + + for _, s := range suites { + err := c.printSuite(s, seen) + if err != nil { + return err + } + } + + return nil +} + +type Pos struct { + Dir string + Filename string + Path string + Line int + Column int +} + +func (p Pos) String() string { + return fmt.Sprintf("%s:%d:%d", p.Path, p.Line, p.Column) +} + +type Entity struct { + Name string + Pos Pos +} + +func (e Entity) String() string { + return e.Name +} + +func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { + newPos := func(p token.Position) Pos { + return Pos{ + Dir: filepath.Dir(p.Filename), + Filename: filepath.Base(p.Filename), + Path: p.Filename, + Line: p.Line, + Column: p.Column, + } + } + + type Data struct { + Package string + Suite Entity + Test Entity + Tests []Entity + } + + data := Data{ + Package: suite.Package.Name, + Suite: Entity{ + Name: suite.Name, + Pos: newPos(suite.FSet.Position(suite.Pos)), + }, + } + + for _, t := range suite.Tests { + data.Tests = append(data.Tests, Entity{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + }) + } + + for _, t := range suite.Tests { + data.Test = Entity{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + } + + line, err := c.Format.Execute(data) + if err != nil { + return err + } + + if seen[line] { + continue + } + + seen[line] = true + + if c.Nul { + fmt.Print(line, "\x00") + } else { + fmt.Println(line) + } + } + + return nil +} diff --git a/cmd/testo/internal/cmd/cmdversion/version.go b/cmd/testo/internal/cmd/cmdversion/version.go new file mode 100644 index 0000000..537818a --- /dev/null +++ b/cmd/testo/internal/cmd/cmdversion/version.go @@ -0,0 +1,34 @@ +package cmdversion + +import ( + "flag" + "fmt" + "runtime" + "runtime/debug" + + "github.com/ozontech/testo/cmd/testo/internal/cli" +) + +func init() { + cli.Add( + "version", + func(*flag.FlagSet, *Cmd) {}, + cli.WithShort("Show testo version"), + cli.WithoutArgs(), + ) +} + +type Cmd struct{} + +func (Cmd) Run(...string) error { + version := "unknown" + + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + + fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) + + return nil +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index b06be90..33b40a4 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -1,513 +1,13 @@ package main import ( - "bufio" - "bytes" - "context" - "errors" - "flag" - "fmt" - "go/token" - "maps" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "runtime/debug" - "slices" - "strings" - "github.com/ozontech/testo/cmd/testo/internal/cli" - "github.com/ozontech/testo/cmd/testo/internal/loader" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdlint" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdrun" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdsuites" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdversion" ) func main() { cli.Run() } - -func init() { - const defaultTags = "example,e2e,integration,functional,smoke" - const defaultTesto = "github.com/ozontech/testo" - - cli.Add("lint", func(f *flag.FlagSet, cmd *Lint) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.BoolVar(&cmd.Load.Strict, "strict", false, "enable strict mode") - f.BoolVar(&cmd.JSON, "json", false, "output json") - }, cli.WithShort("Run testo linter")) - - cli.Add("run", func(f *flag.FlagSet, cmd *Run) { - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.StringVar(&cmd.Sep, "s", ".", "pattern separator") - f.BoolVar(&cmd.N, "n", false, "print the commands but do not run them") - f.BoolVar(&cmd.Verbose, "v", false, "verbose output") - f.BoolVar(&cmd.JSON, "json", false, "log verbose output and test results in JSON") - }, - cli.WithShort("Run testo suites"), - cli.WithUsage(`[flags] [pattern] [flags] -- [test flags] - -Pattern: - suite suite regex - suite.test suite and test regex - package.suite.test package, suite and test regex`), - ) - - cli.Add("suites", func(f *flag.FlagSet, cmd *Suites) { - cmd.Format.Set("{{ .Suite.Name }}") - - f.StringVar(&cmd.Load.Tags, "tags", defaultTags, "build tags separated by comma") - f.StringVar(&cmd.Load.Testo, "testo", defaultTesto, "testo package") - f.Var(&cmd.Format, "f", "output format") - f.BoolVar(&cmd.Nul, "0", false, "output each line delimited by NUL byte") - }, - cli.WithShort("Show testo suites"), - cli.WithUsage(`[flags] [pattern...] [flags] - -Format: - flag -f accepts Go text/template string with the following data as input: - - type Data struct { - Package string - Suite Entity - Test Entity - Tests []Entity - } - - type Entity struct { - Name string - Pos Pos - } - - type Pos struct { - Dir string - Filename string - Path string - Line int - Column int - } - -Examples: - pick suite test with fzf and bat preview - - testo suites ./... -0 -f '{{ .Package }}.{{ .Suite.Name }}.{{ .Test.Name }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up -`), - ) - - cli.Add( - "version", - func(*flag.FlagSet, *Version) {}, - cli.WithShort("Show testo version"), - cli.WithoutArgs(), - ) -} - -type Version struct{} - -func (cmd Version) Run(...string) error { - version := "unknown" - - info, ok := debug.ReadBuildInfo() - if ok { - version = info.Main.Version - } - - fmt.Printf("testo version %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) - - return nil -} - -type Lint struct { - Load loader.Config - JSON bool -} - -func (cmd Lint) Run(patterns ...string) error { - _, err := loader.Load(cmd.Load, patterns...) - if err == nil { - return nil - } - - var errLoad *loader.LoadError - - if !errors.As(err, &errLoad) { - return err - } - - var buf bytes.Buffer - - for _, d := range errLoad.Diagnostics { - if cmd.JSON { - d.JSON(&buf, errLoad.FSet) - } else { - d.Print(&buf, errLoad.FSet) - } - } - - return cli.Exit(1).Stdout(buf.String()) -} - -type Suites struct { - Load loader.Config - Format cli.FlagTemplate - Nul bool -} - -func (cmd Suites) Run(patterns ...string) error { - suites, err := loader.Load(cmd.Load, patterns...) - if err != nil { - return err - } - - seen := make(map[string]bool) - - for _, s := range suites { - err := cmd.printSuite(s, seen) - if err != nil { - return err - } - } - - return nil -} - -func (cmd Suites) printSuite(suite loader.Suite, seen map[string]bool) error { - type Pos struct { - Dir string - Filename string - Path string - Line int - Column int - } - - newPos := func(p token.Position) Pos { - return Pos{ - Dir: filepath.Dir(p.Filename), - Filename: filepath.Base(p.Filename), - Path: p.Filename, - Line: p.Line, - Column: p.Column, - } - } - - type Entity struct { - Name string - Pos Pos - } - - type Data struct { - Package string - Suite Entity - Test Entity - Tests []Entity - } - - data := Data{ - Package: suite.Package.Name, - Suite: Entity{ - Name: suite.Name, - Pos: newPos(suite.FSet.Position(suite.Pos)), - }, - } - - for _, t := range suite.Tests { - data.Tests = append(data.Tests, Entity{ - Name: t.Name, - Pos: newPos(suite.FSet.Position(t.Pos)), - }) - } - - w := bufio.NewWriter(os.Stdout) - defer w.Flush() - - for _, t := range suite.Tests { - data.Test = Entity{ - Name: t.Name, - Pos: newPos(suite.FSet.Position(t.Pos)), - } - - line, err := cmd.Format.Execute(data) - if err != nil { - return err - } - - if seen[line] { - continue - } - - seen[line] = true - - if cmd.Nul { - fmt.Fprint(w, line, "\x00") - } else { - fmt.Fprintln(w, line) - } - } - - return nil -} - -type Run struct { - Load loader.Config - Sep string - N bool - Verbose bool - JSON bool -} - -type runMatched struct { - Suite loader.Suite - Tests map[string]struct{} -} - -func (cmd Run) Run(patterns ...string) error { - id, extraFlags, err := cmd.parse(patterns...) - if err != nil { - return err - } - - suites, err := loader.Load(cmd.Load, "./...") - if err != nil { - return err - } - - matched := make(map[string]runMatched) - - if id != nil { - for _, s := range suites { - tests, ok := id.match(s) - - if !ok { - continue - } - - key := s.Package.Name + "." + s.Name - - if m, ok := matched[key]; ok { - maps.Copy(m.Tests, tests) - } else { - matched[key] = runMatched{ - Suite: s, - Tests: tests, - } - } - } - } else { - for _, s := range suites { - key := s.Package.Name + "." + s.Name - - matched[key] = runMatched{Suite: s} - } - } - - if len(matched) == 0 { - if id != nil { - return fmt.Errorf("%q did not match any suites", id.Source) - } - - return errors.New("testo suites not found") - } - - c, err := cmd.buildGoTest(slices.Collect(maps.Values(matched)), extraFlags) - if err != nil { - return fmt.Errorf("failed to build go test command: %w", err) - } - - if cmd.N { - fmt.Println(c.String()) - - return nil - } - - return c.Run() -} - -func (cmd Run) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error) { - packages := make(map[string]struct{}) - suiteCallers := make(map[string]struct{}) - tests := make(map[string]struct{}) - - for _, m := range matched { - runners, err := m.Suite.Runners(context.Background()) - if err != nil { - return nil, fmt.Errorf("find runners for suite %q: %w", m.Suite.Name, err) - } - - for _, r := range runners { - packages[r.Dir] = struct{}{} - suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} - } - - for t := range m.Tests { - tests[t] = struct{}{} - } - } - - if len(matched) > 0 && len(packages) == 0 { - return nil, errors.New("suite callers not found") - } - - args := []string{"test", "-tags", cmd.Load.Tags} - - if cmd.Verbose { - args = append(args, "-v") - } - - if cmd.JSON { - args = append(args, "-json") - } - - for p := range packages { - args = append(args, p) - } - - if len(packages) == 0 { - args = append(args, ".") - } - - if len(suiteCallers) > 0 { - args = append( - args, - "-run", - strings.Join(slices.Sorted(maps.Keys(suiteCallers)), "|"), - ) - } - - if len(tests) > 0 { - args = append( - args, - "-testo.m", - fmt.Sprintf( - "^(%s)$", - strings.Join(slices.Sorted(maps.Keys(tests)), "|"), - ), - ) - } - - args = append(args, extra...) - - c := exec.Command("go", args...) - - c.Stdout = os.Stdout - c.Stderr = os.Stderr - c.Env = os.Environ() - - return c, nil -} - -func (cmd Run) parse(args ...string) (id *runID, extra []string, err error) { - for i, p := range args { - if strings.HasPrefix(p, "-") { - extra = append(extra, args[i:]...) - - break - } - - if id != nil { - return nil, nil, fmt.Errorf("unexpected positional argument: %q", p) - } - - parsed, err := cmd.id(p) - if err != nil { - return nil, nil, fmt.Errorf("parse %q: %w", p, err) - } - - id = &parsed - } - - return id, extra, nil -} - -func (cmd Run) id(pattern string) (runID, error) { - fields := strings.Split(pattern, cmd.Sep) - - switch len(fields) { - case 1: // suite - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - return runID{ - Suite: suite, - Source: pattern, - }, nil - - case 2: // suite.test - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - test, err := regexp.Compile(fields[1]) - if err != nil { - return runID{}, err - } - - return runID{ - Suite: suite, - Test: test, - Source: pattern, - }, nil - - case 3: // package.suite.test - pkg, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } - - suite, err := regexp.Compile(fields[1]) - if err != nil { - return runID{}, err - } - - test, err := regexp.Compile(fields[2]) - if err != nil { - return runID{}, err - } - - return runID{ - Package: pkg, - Suite: suite, - Test: test, - Source: pattern, - }, nil - - default: - return runID{}, errors.New("invalid syntax") - } -} - -type runID struct { - Package *regexp.Regexp - Suite *regexp.Regexp - Test *regexp.Regexp - - Source string -} - -func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { - if id.Package != nil && !id.Package.MatchString(suite.Package.Name) { - return nil, false - } - - if id.Suite != nil && !id.Suite.MatchString(suite.Name) { - return nil, false - } - - if id.Test == nil { - return nil, true - } - - tests = make(map[string]struct{}) - - for _, t := range suite.Tests { - if id.Test.MatchString(t.Name) { - tests[t.Name] = struct{}{} - ok = true - } - } - - return tests, ok -} From 90fef28735c24b796e3e4cb804983ec2a0cb718d Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 14:22:27 +0300 Subject: [PATCH 33/47] fix newlines in lint cmd --- cmd/testo/internal/cmd/cmdlint/lint.go | 2 +- cmd/testo/internal/cmd/cmdsuites/suites.go | 28 ----------------- cmd/testo/internal/cmd/cmdsuites/template.go | 33 ++++++++++++++++++++ cmd/testo/internal/loader/loader.go | 11 ++++++- 4 files changed, 44 insertions(+), 30 deletions(-) create mode 100644 cmd/testo/internal/cmd/cmdsuites/template.go diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go index 58d0982..cc9bb0e 100644 --- a/cmd/testo/internal/cmd/cmdlint/lint.go +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -42,7 +42,7 @@ func (c Cmd) Run(patterns ...string) error { if c.JSON { d.JSON(&buf, errLoad.FSet) } else { - d.Print(&buf, errLoad.FSet) + d.Println(&buf, errLoad.FSet) } } diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go index 3d839a1..38f901d 100644 --- a/cmd/testo/internal/cmd/cmdsuites/suites.go +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -78,27 +78,6 @@ func (c Cmd) Run(patterns ...string) error { return nil } -type Pos struct { - Dir string - Filename string - Path string - Line int - Column int -} - -func (p Pos) String() string { - return fmt.Sprintf("%s:%d:%d", p.Path, p.Line, p.Column) -} - -type Entity struct { - Name string - Pos Pos -} - -func (e Entity) String() string { - return e.Name -} - func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { newPos := func(p token.Position) Pos { return Pos{ @@ -110,13 +89,6 @@ func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { } } - type Data struct { - Package string - Suite Entity - Test Entity - Tests []Entity - } - data := Data{ Package: suite.Package.Name, Suite: Entity{ diff --git a/cmd/testo/internal/cmd/cmdsuites/template.go b/cmd/testo/internal/cmd/cmdsuites/template.go new file mode 100644 index 0000000..8b259c6 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdsuites/template.go @@ -0,0 +1,33 @@ +package cmdsuites + +import "fmt" + +type ( + Data struct { + Package string + Suite Entity + Test Entity + Tests []Entity + } + + Entity struct { + Name string + Pos Pos + } + + Pos struct { + Dir string + Filename string + Path string + Line int + Column int + } +) + +func (e Entity) String() string { + return e.Name +} + +func (p Pos) String() string { + return fmt.Sprintf("%s:%d:%d", p.Path, p.Line, p.Column) +} diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index d85720d..9ff42c1 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -1,6 +1,7 @@ package loader import ( + "bufio" "bytes" "cmp" "context" @@ -521,7 +522,15 @@ func (d Diagnostic) Print(w io.Writer, set *token.FileSet) { file := set.File(d.Pos) line := file.Line(d.Pos) - fmt.Fprintf(w, "%s:%d: %s\n", file.Name(), line, d.Issue.String()) + fmt.Fprintf(w, "%s:%d: %s", file.Name(), line, d.Issue.String()) +} + +func (d Diagnostic) Println(w io.Writer, set *token.FileSet) { + b := bufio.NewWriter(w) + defer b.Flush() + + d.Print(b, set) + fmt.Fprintln(b) } type Issue interface { From 9dd81d6f4a1598082dbb9ea003afd270a49f3234 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 5 Jul 2026 14:34:20 +0300 Subject: [PATCH 34/47] show types in suites cmd from source code --- cmd/testo/internal/cmd/cmdsuites/suites.go | 22 +----- cmd/testo/internal/cmd/cmdsuites/template.go | 79 ++++++++++++++------ 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go index 38f901d..ce2085a 100644 --- a/cmd/testo/internal/cmd/cmdsuites/suites.go +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -26,30 +26,12 @@ func init() { Format: flag -f accepts Go text/template string with the following data as input: - type Data struct { - Package string - Suite Entity - Test Entity - Tests []Entity - } - - type Entity struct { - Name string - Pos Pos - } - - type Pos struct { - Dir string - Filename string - Path string - Line int - Column int - } +`+templateTypes("\t")+` Examples: pick suite test with fzf and bat preview - testo suites ./... -0 -f '{{ .Package }}.{{ .Suite.Name }}.{{ .Test.Name }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up + testo suites ./... -0 -f '{{ .Package }}.{{ .Suite }}.{{ .Test }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up `), ) } diff --git a/cmd/testo/internal/cmd/cmdsuites/template.go b/cmd/testo/internal/cmd/cmdsuites/template.go index 8b259c6..391b081 100644 --- a/cmd/testo/internal/cmd/cmdsuites/template.go +++ b/cmd/testo/internal/cmd/cmdsuites/template.go @@ -1,28 +1,38 @@ package cmdsuites -import "fmt" - -type ( - Data struct { - Package string - Suite Entity - Test Entity - Tests []Entity - } +import ( + "bytes" + _ "embed" + "fmt" + "strings" + "unicode" +) - Entity struct { - Name string - Pos Pos - } +//go:embed template.go +var currentFile string - Pos struct { - Dir string - Filename string - Path string - Line int - Column int - } -) +//types:start +type Data struct { + Package string // package name + Suite Entity // current suite + Test Entity // current test for this suite + Tests []Entity // all tests +} + +type Entity struct { + Name string + Pos Pos +} + +type Pos struct { + Dir string // file dir + Filename string // base file name + Path string // absolute path + Line int // 1-based line number + Column int // 1-based column number +} + +//types:end func (e Entity) String() string { return e.Name @@ -31,3 +41,30 @@ func (e Entity) String() string { func (p Pos) String() string { return fmt.Sprintf("%s:%d:%d", p.Path, p.Line, p.Column) } + +func templateTypes(indent string) string { + const start = "//types:start" + const end = "//types:end" + + var inTypes bool + + var buf bytes.Buffer + +lines: + for line := range strings.Lines(currentFile) { + switch { + case strings.HasPrefix(line, start): + inTypes = true + + case strings.HasPrefix(line, end): + break lines + + case inTypes: + buf.WriteString(indent) + buf.WriteString(strings.TrimRightFunc(line, unicode.IsSpace)) + buf.WriteString("\n") + } + } + + return strings.TrimRightFunc(buf.String(), unicode.IsSpace) +} From 063652fe53db74445087d9c1dcac9aa26c178bbd Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 6 Jul 2026 00:36:15 +0300 Subject: [PATCH 35/47] add tags command --- cmd/testo/internal/cli/errors.go | 6 ++ cmd/testo/internal/cmd/cmdtags/tags.go | 80 +++++++++++++++ cmd/testo/internal/loader/tags.go | 135 +++++++++++++++++++++++++ cmd/testo/main.go | 1 + 4 files changed, 222 insertions(+) create mode 100644 cmd/testo/internal/cmd/cmdtags/tags.go create mode 100644 cmd/testo/internal/loader/tags.go diff --git a/cmd/testo/internal/cli/errors.go b/cmd/testo/internal/cli/errors.go index d1d46d5..2bca208 100644 --- a/cmd/testo/internal/cli/errors.go +++ b/cmd/testo/internal/cli/errors.go @@ -28,6 +28,12 @@ func (e ExitError) Stdout(s string) ExitError { return e } +func (e ExitError) Stderr(s string) ExitError { + e.stderr = s + + return e +} + func (e ExitError) Print() { if e.stdout != "" { fmt.Fprint(os.Stdout, e.stdout) diff --git a/cmd/testo/internal/cmd/cmdtags/tags.go b/cmd/testo/internal/cmd/cmdtags/tags.go new file mode 100644 index 0000000..f276452 --- /dev/null +++ b/cmd/testo/internal/cmd/cmdtags/tags.go @@ -0,0 +1,80 @@ +package cmdtags + +import ( + "cmp" + "context" + "flag" + "fmt" + "maps" + "slices" + "strings" + + "github.com/ozontech/testo/cmd/testo/internal/cli" + "github.com/ozontech/testo/cmd/testo/internal/loader" +) + +func init() { + cli.Add("tags", func(f *flag.FlagSet, cmd *Cmd) { + f.BoolVar(&cmd.Tests, "tests", false, "only show build tags used in *_test.go files") + }, + cli.WithShort("Show project build tags"), + cli.WithoutArgs(), + ) +} + +type Cmd struct { + Tests bool +} + +func (c Cmd) Run(...string) error { + add, remove, err := loader.BuildTags(context.Background(), c.Tests) + if err != nil { + return err + } + + conflicting := intersection(add, remove) + + if len(conflicting) > 0 { + return cli.Exit(2).Stderr("conflicting tags: " + join(keys(conflicting))) + } + + for r := range remove { + delete(add, r) + } + + if len(add) > 0 { + fmt.Println(join(keys(add))) + } + + return nil +} + +func join(s []string) string { + return strings.Join(s, ",") +} + +func keys[M ~map[K]V, K cmp.Ordered, V any](m M) []K { + s := slices.Collect(maps.Keys(m)) + + slices.Sort(s) + + return s +} + +func intersection[M ~map[K]struct{}, K comparable](a, b M) M { + m := make(M) + + for k := range a { + if _, ok := b[k]; ok { + m[k] = struct{}{} + } + } + + for k := range b { + if _, ok := a[k]; ok { + m[k] = struct{}{} + } + } + + return m +} diff --git a/cmd/testo/internal/loader/tags.go b/cmd/testo/internal/loader/tags.go new file mode 100644 index 0000000..d61d71c --- /dev/null +++ b/cmd/testo/internal/loader/tags.go @@ -0,0 +1,135 @@ +package loader + +import ( + "context" + "encoding/json" + "fmt" + "go/ast" + "go/build/constraint" + "go/parser" + "go/token" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func BuildTags(ctx context.Context, testOnly bool) (add, remove map[string]struct{}, err error) { + add = make(map[string]struct{}) + remove = make(map[string]struct{}) + + files, err := listGoFiles(ctx, testOnly) + if err != nil { + return nil, nil, fmt.Errorf("list test files: %w", err) + } + + for _, f := range files { + buildTags(f, add, remove) + } + + return add, remove, nil +} + +func buildTags(file *ast.File, add, remove map[string]struct{}) { + for _, g := range file.Comments { + for _, c := range g.List { + if !constraint.IsGoBuild(c.Text) && !constraint.IsPlusBuild(c.Text) { + continue + } + + expr, err := constraint.Parse(c.Text) + if err != nil { + continue + } + + addConstraintExpr(expr, add, remove) + + } + } +} + +func addConstraintExpr(e constraint.Expr, add, remove map[string]struct{}) { + switch e := e.(type) { + case *constraint.AndExpr: + addConstraintExpr(e.X, add, remove) + addConstraintExpr(e.Y, add, remove) + + case *constraint.NotExpr: + addConstraintExpr(e.X, remove, add) + + case *constraint.OrExpr: + addConstraintExpr(e.X, add, remove) + addConstraintExpr(e.Y, add, remove) + + case *constraint.TagExpr: + add[e.Tag] = struct{}{} + } +} + +func listGoFiles(ctx context.Context, testOnly bool) ([]*ast.File, error) { + g := exec.CommandContext(ctx, "go", "list", "-m", "-json") + + out, err := g.Output() + if err != nil { + return nil, err + } + + var mod struct { + Dir string + } + + err = json.Unmarshal(out, &mod) + if err != nil { + return nil, err + } + + if mod.Dir == "" { + return nil, fmt.Errorf("outside of go module") + } + + files := make(map[string]struct{}) + + fs.WalkDir(os.DirFS(mod.Dir), ".", func(path string, d fs.DirEntry, err error) error { + if path == "testdata" && d.IsDir() { + return fs.SkipDir + } + + if d.IsDir() { + return nil + } + + suffix := ".go" + if testOnly { + suffix = "_test.go" + } + + if !strings.HasSuffix(d.Name(), suffix) { + return nil + } + + files[filepath.Join(mod.Dir, path)] = struct{}{} + + return nil + }) + + s := make([]*ast.File, 0, len(files)) + + fset := token.NewFileSet() + + for f := range files { + parsed, err := parser.ParseFile( + fset, + f, + nil, + parser.ParseComments|parser.PackageClauseOnly, + ) + if err != nil { + return nil, err + } + + s = append(s, parsed) + } + + return s, nil +} diff --git a/cmd/testo/main.go b/cmd/testo/main.go index 33b40a4..5eaba68 100644 --- a/cmd/testo/main.go +++ b/cmd/testo/main.go @@ -5,6 +5,7 @@ import ( _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdlint" _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdrun" _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdsuites" + _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdtags" _ "github.com/ozontech/testo/cmd/testo/internal/cmd/cmdversion" ) From 5d6ebc0d4d9db6a7939147ada76b27d3e9035b4f Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 6 Jul 2026 10:13:00 +0300 Subject: [PATCH 36/47] improve tags command --- cmd/testo/internal/cmd/cmdtags/tags.go | 45 +++++++++++--------------- cmd/testo/internal/loader/tags.go | 2 +- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/cmd/testo/internal/cmd/cmdtags/tags.go b/cmd/testo/internal/cmd/cmdtags/tags.go index f276452..e54b8f5 100644 --- a/cmd/testo/internal/cmd/cmdtags/tags.go +++ b/cmd/testo/internal/cmd/cmdtags/tags.go @@ -16,13 +16,28 @@ import ( func init() { cli.Add("tags", func(f *flag.FlagSet, cmd *Cmd) { f.BoolVar(&cmd.Tests, "tests", false, "only show build tags used in *_test.go files") + f.BoolVar(&cmd.All, "a", false, "include build tags cancelled by negations, e.g. //go:build !tag") }, cli.WithShort("Show project build tags"), + cli.WithLong(`Show project build tags. + +It traverses all go files and parses //go:build directives. +Use -tests flag to traverse only *_test.go files. + +If same tag is both required and cancelled by different expressions it will be omitted. +Pass -a flag to change that. + + //go:build mytag + //go:build !mytag + +This command must be executed from the same directory as go module (project). +`), cli.WithoutArgs(), ) } type Cmd struct { + All bool Tests bool } @@ -32,14 +47,10 @@ func (c Cmd) Run(...string) error { return err } - conflicting := intersection(add, remove) - - if len(conflicting) > 0 { - return cli.Exit(2).Stderr("conflicting tags: " + join(keys(conflicting))) - } - - for r := range remove { - delete(add, r) + if !c.All { + for k := range remove { + delete(add, k) + } } if len(add) > 0 { @@ -60,21 +71,3 @@ func keys[M ~map[K]V, K cmp.Ordered, V any](m M) []K { return s } - -func intersection[M ~map[K]struct{}, K comparable](a, b M) M { - m := make(M) - - for k := range a { - if _, ok := b[k]; ok { - m[k] = struct{}{} - } - } - - for k := range b { - if _, ok := a[k]; ok { - m[k] = struct{}{} - } - } - - return m -} diff --git a/cmd/testo/internal/loader/tags.go b/cmd/testo/internal/loader/tags.go index d61d71c..3b8a8e0 100644 --- a/cmd/testo/internal/loader/tags.go +++ b/cmd/testo/internal/loader/tags.go @@ -21,7 +21,7 @@ func BuildTags(ctx context.Context, testOnly bool) (add, remove map[string]struc files, err := listGoFiles(ctx, testOnly) if err != nil { - return nil, nil, fmt.Errorf("list test files: %w", err) + return nil, nil, err } for _, f := range files { From 84a2662a1f37fbcb1e14e7c146af9ceb3e44b863 Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 6 Jul 2026 10:19:32 +0300 Subject: [PATCH 37/47] derive build tags if not passed --- cmd/testo/internal/cmd/cmd.go | 5 +---- cmd/testo/internal/cmd/cmdlint/lint.go | 2 +- cmd/testo/internal/cmd/cmdrun/run.go | 2 +- cmd/testo/internal/cmd/cmdsuites/suites.go | 2 +- cmd/testo/internal/cmd/cmdtags/tags.go | 3 +-- cmd/testo/internal/loader/loader.go | 17 +++++++++++++++++ cmd/testo/internal/loader/tags.go | 4 ++-- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/cmd/testo/internal/cmd/cmd.go b/cmd/testo/internal/cmd/cmd.go index 4a4195f..cdd3539 100644 --- a/cmd/testo/internal/cmd/cmd.go +++ b/cmd/testo/internal/cmd/cmd.go @@ -1,6 +1,3 @@ package cmd -const ( - DefaultTags = "example,e2e,integration,functional,smoke" - DefaultTesto = "github.com/ozontech/testo" -) +const DefaultTesto = "github.com/ozontech/testo" diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go index cc9bb0e..ec01553 100644 --- a/cmd/testo/internal/cmd/cmdlint/lint.go +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -12,7 +12,7 @@ import ( func init() { cli.Add("lint", func(f *flag.FlagSet, c *Cmd) { - f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.BoolVar(&c.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&c.JSON, "json", false, "output json") diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go index 1774e6e..a8f479e 100644 --- a/cmd/testo/internal/cmd/cmdrun/run.go +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -19,7 +19,7 @@ import ( func init() { cli.Add("run", func(f *flag.FlagSet, c *Cmd) { - f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.StringVar(&c.Sep, "s", ".", "pattern separator") f.BoolVar(&c.N, "n", false, "print the commands but do not run them") diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go index ce2085a..e1d17a9 100644 --- a/cmd/testo/internal/cmd/cmdsuites/suites.go +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -15,7 +15,7 @@ func init() { cli.Add("suites", func(f *flag.FlagSet, c *Cmd) { c.Format.Set("{{ .Suite }}") - f.StringVar(&c.Load.Tags, "tags", cmd.DefaultTags, "build tags separated by comma") + f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.Var(&c.Format, "f", "output format") f.BoolVar(&c.Nul, "0", false, "output each line delimited by NUL byte") diff --git a/cmd/testo/internal/cmd/cmdtags/tags.go b/cmd/testo/internal/cmd/cmdtags/tags.go index e54b8f5..37d3511 100644 --- a/cmd/testo/internal/cmd/cmdtags/tags.go +++ b/cmd/testo/internal/cmd/cmdtags/tags.go @@ -2,7 +2,6 @@ package cmdtags import ( "cmp" - "context" "flag" "fmt" "maps" @@ -42,7 +41,7 @@ type Cmd struct { } func (c Cmd) Run(...string) error { - add, remove, err := loader.BuildTags(context.Background(), c.Tests) + add, remove, err := loader.BuildTags(c.Tests) if err != nil { return err } diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index 9ff42c1..e981030 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -46,6 +46,23 @@ type Config struct { } func Load(cfg Config, patterns ...string) ([]Suite, error) { + if cfg.Tags == "" { + add, remove, err := BuildTags(false) + if err == nil { + tags := make([]string, 0, len(add)) + + for k := range add { + if _, ok := remove[k]; !ok { + tags = append(tags, k) + } + } + + slices.Sort(tags) + + cfg.Tags = strings.Join(tags, ",") + } + } + fset := token.NewFileSet() pkgs, err := packageslite.Load(packageslite.Config{ diff --git a/cmd/testo/internal/loader/tags.go b/cmd/testo/internal/loader/tags.go index 3b8a8e0..b362445 100644 --- a/cmd/testo/internal/loader/tags.go +++ b/cmd/testo/internal/loader/tags.go @@ -15,11 +15,11 @@ import ( "strings" ) -func BuildTags(ctx context.Context, testOnly bool) (add, remove map[string]struct{}, err error) { +func BuildTags(testOnly bool) (add, remove map[string]struct{}, err error) { add = make(map[string]struct{}) remove = make(map[string]struct{}) - files, err := listGoFiles(ctx, testOnly) + files, err := listGoFiles(context.Background(), testOnly) if err != nil { return nil, nil, err } From 8ffebdd6f38ba13e1de042c4c03dd7f8a392d206 Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 6 Jul 2026 10:21:03 +0300 Subject: [PATCH 38/47] show lint usage --- cmd/testo/internal/cmd/cmdlint/lint.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go index ec01553..6c8be98 100644 --- a/cmd/testo/internal/cmd/cmdlint/lint.go +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -16,7 +16,9 @@ func init() { f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.BoolVar(&c.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&c.JSON, "json", false, "output json") - }, cli.WithShort("Run testo linter")) + }, + cli.WithUsage("[flags] [pattern...] [flags]"), + cli.WithShort("Run testo linter")) } type Cmd struct { From a7098617e0fc1b2ab47547cbcfdf6c36ae079708 Mon Sep 17 00:00:00 2001 From: metafates Date: Mon, 6 Jul 2026 18:45:17 +0300 Subject: [PATCH 39/47] pass build tags for run --- cmd/testo/internal/cmd/cmdrun/run.go | 14 +++++++++++++- cmd/testo/internal/loader/loader.go | 1 + cmd/testo/internal/loader/runner.go | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go index a8f479e..87bc6eb 100644 --- a/cmd/testo/internal/cmd/cmdrun/run.go +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -116,6 +116,9 @@ func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error suiteCallers := make(map[string]struct{}) tests := make(map[string]struct{}) + seenTags := make(map[string]bool) + var tags []string + for _, m := range matched { runners, err := m.Suite.Runners(context.Background()) if err != nil { @@ -125,6 +128,15 @@ func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error for _, r := range runners { packages[r.Dir] = struct{}{} suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} + + for _, t := range strings.Split(r.Tags, ",") { + if seenTags[t] { + continue + } + + seenTags[t] = true + tags = append(tags, t) + } } for t := range m.Tests { @@ -136,7 +148,7 @@ func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error return nil, errors.New("suite callers not found") } - args := []string{"test", "-tags", c.Load.Tags} + args := []string{"test", "-tags", strings.Join(tags, ",")} if c.Verbose { args = append(args, "-v") diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index e981030..df4fe36 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -637,6 +637,7 @@ type Suite struct { type SuiteRunner struct { Dir string Name string + Tags string } type T struct { diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go index d371919..a4e9b2c 100644 --- a/cmd/testo/internal/loader/runner.go +++ b/cmd/testo/internal/loader/runner.go @@ -93,6 +93,7 @@ func (c *Config) loadRunners( r := SuiteRunner{ Name: testName, Dir: filepath.Dir(tokenFile.Name()), + Tags: c.Tags, } if identical { From af11c6b02183157b25346a3a9412ebca34487624 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 20:43:11 +0300 Subject: [PATCH 40/47] overall cmd improvements --- cmd/testo/internal/cmd/cmdrun/run.go | 87 ++++++++------------ cmd/testo/internal/cmd/cmdsuites/suites.go | 10 ++- cmd/testo/internal/cmd/cmdsuites/template.go | 12 ++- cmd/testo/internal/loader/loader.go | 4 + cmd/testo/internal/loader/runner.go | 7 +- cmd/testo/internal/loader/tags.go | 45 ++++++---- cmd/testo/internal/typeutil/util.go | 13 +-- 7 files changed, 96 insertions(+), 82 deletions(-) diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go index 87bc6eb..855cac8 100644 --- a/cmd/testo/internal/cmd/cmdrun/run.go +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -21,7 +21,6 @@ func init() { cli.Add("run", func(f *flag.FlagSet, c *Cmd) { f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") - f.StringVar(&c.Sep, "s", ".", "pattern separator") f.BoolVar(&c.N, "n", false, "print the commands but do not run them") f.BoolVar(&c.Verbose, "v", false, "verbose output") f.BoolVar(&c.JSON, "json", false, "log verbose output and test results in JSON") @@ -29,16 +28,18 @@ func init() { cli.WithShort("Run testo suites"), cli.WithUsage(`[flags] [pattern] [flags] -- [test flags] -Pattern: +Patterns: suite suite regex suite.test suite and test regex - package.suite.test package, suite and test regex`), + .test test regex + package/suite package and suite + package/suite.test package, suite and test regex + package/ package regex`), ) } type Cmd struct { Load loader.Config - Sep string N bool Verbose bool JSON bool @@ -70,12 +71,12 @@ func (c Cmd) Run(patterns ...string) error { continue } - key := s.Package.Name + "." + s.Name + id := s.ID() - if m, ok := matched[key]; ok { + if m, ok := matched[id]; ok { maps.Copy(m.Tests, tests) } else { - matched[key] = runMatched{ + matched[id] = runMatched{ Suite: s, Tests: tests, } @@ -83,9 +84,7 @@ func (c Cmd) Run(patterns ...string) error { } } else { for _, s := range suites { - key := s.Package.Name + "." + s.Name - - matched[key] = runMatched{Suite: s} + matched[s.ID()] = runMatched{Suite: s} } } @@ -129,7 +128,7 @@ func (c Cmd) buildGoTest(matched []runMatched, extra []string) (*exec.Cmd, error packages[r.Dir] = struct{}{} suiteCallers[fmt.Sprintf("^%s$/^%s$", r.Name, m.Suite.Name)] = struct{}{} - for _, t := range strings.Split(r.Tags, ",") { + for t := range strings.SplitSeq(r.Tags, ",") { if seenTags[t] { continue } @@ -220,63 +219,43 @@ func (c Cmd) parse(args ...string) (id *runID, extra []string, err error) { } func (c Cmd) id(pattern string) (runID, error) { - fields := strings.Split(pattern, c.Sep) + id := runID{Source: pattern} - switch len(fields) { - case 1: // suite - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } + var pkg, suite, test string - return runID{ - Suite: suite, - Source: pattern, - }, nil + pattern, rest, ok := strings.Cut(pattern, "/") + if ok { + pkg = pattern - case 2: // suite.test - suite, err := regexp.Compile(fields[0]) - if err != nil { - return runID{}, err - } + pattern = rest + } - test, err := regexp.Compile(fields[1]) - if err != nil { - return runID{}, err - } + suite, test, _ = strings.Cut(pattern, ".") - return runID{ - Suite: suite, - Test: test, - Source: pattern, - }, nil + var err error - case 3: // package.suite.test - pkg, err := regexp.Compile(fields[0]) + if pkg != "" { + id.Package, err = regexp.Compile(pkg) if err != nil { - return runID{}, err + return runID{}, fmt.Errorf("parse package %q: %w", pkg, err) } + } - suite, err := regexp.Compile(fields[1]) + if suite != "" { + id.Suite, err = regexp.Compile(suite) if err != nil { - return runID{}, err + return runID{}, fmt.Errorf("parse suite %q: %w", suite, err) } + } - test, err := regexp.Compile(fields[2]) + if test != "" { + id.Test, err = regexp.Compile(test) if err != nil { - return runID{}, err + return runID{}, fmt.Errorf("parse test %q: %w", test, err) } - - return runID{ - Package: pkg, - Suite: suite, - Test: test, - Source: pattern, - }, nil - - default: - return runID{}, errors.New("invalid syntax") } + + return id, nil } type runID struct { @@ -288,7 +267,7 @@ type runID struct { } func (id runID) match(suite loader.Suite) (tests map[string]struct{}, ok bool) { - if id.Package != nil && !id.Package.MatchString(suite.Package.Name) { + if id.Package != nil && !id.Package.MatchString(suite.Package.Path) { return nil, false } diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go index e1d17a9..f7c29a7 100644 --- a/cmd/testo/internal/cmd/cmdsuites/suites.go +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -13,7 +13,7 @@ import ( func init() { cli.Add("suites", func(f *flag.FlagSet, c *Cmd) { - c.Format.Set("{{ .Suite }}") + c.Format.Set("{{ .Package }}/{{ .Suite }}") f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") @@ -31,7 +31,7 @@ Format: Examples: pick suite test with fzf and bat preview - testo suites ./... -0 -f '{{ .Package }}.{{ .Suite }}.{{ .Test }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up + testo suites ./... -0 -f '{{ .Package }}/{{ .Suite }}.{{ .Test }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up `), ) } @@ -72,7 +72,11 @@ func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { } data := Data{ - Package: suite.Package.Name, + Package: Package{ + Name: suite.Package.Name, + Path: suite.Package.Path, + Dir: suite.Package.Dir, + }, Suite: Entity{ Name: suite.Name, Pos: newPos(suite.FSet.Position(suite.Pos)), diff --git a/cmd/testo/internal/cmd/cmdsuites/template.go b/cmd/testo/internal/cmd/cmdsuites/template.go index 391b081..c81963f 100644 --- a/cmd/testo/internal/cmd/cmdsuites/template.go +++ b/cmd/testo/internal/cmd/cmdsuites/template.go @@ -13,12 +13,18 @@ var currentFile string //types:start type Data struct { - Package string // package name + Package Package // suite package Suite Entity // current suite Test Entity // current test for this suite Tests []Entity // all tests } +type Package struct { + Name string + Path string + Dir string +} + type Entity struct { Name string Pos Pos @@ -34,6 +40,10 @@ type Pos struct { //types:end +func (p Package) String() string { + return p.Name +} + func (e Entity) String() string { return e.Name } diff --git a/cmd/testo/internal/loader/loader.go b/cmd/testo/internal/loader/loader.go index df4fe36..8b1b70c 100644 --- a/cmd/testo/internal/loader/loader.go +++ b/cmd/testo/internal/loader/loader.go @@ -634,6 +634,10 @@ type Suite struct { Type types.Type } +func (s Suite) ID() string { + return s.Package.Path + "." + s.Name +} + type SuiteRunner struct { Dir string Name string diff --git a/cmd/testo/internal/loader/runner.go b/cmd/testo/internal/loader/runner.go index a4e9b2c..2777ea8 100644 --- a/cmd/testo/internal/loader/runner.go +++ b/cmd/testo/internal/loader/runner.go @@ -75,11 +75,14 @@ func (c *Config) loadRunners( return true } - sig := pkg.Info.Types[call.Fun].Type.(*types.Signature) + sig, ok := pkg.Info.Types[call.Fun].Type.(*types.Signature) + if !ok { + return true + } params := sig.Params() - if params.Len() == 0 { + if params.Len() < 2 { return true } diff --git a/cmd/testo/internal/loader/tags.go b/cmd/testo/internal/loader/tags.go index b362445..5bc8099 100644 --- a/cmd/testo/internal/loader/tags.go +++ b/cmd/testo/internal/loader/tags.go @@ -90,28 +90,39 @@ func listGoFiles(ctx context.Context, testOnly bool) ([]*ast.File, error) { files := make(map[string]struct{}) - fs.WalkDir(os.DirFS(mod.Dir), ".", func(path string, d fs.DirEntry, err error) error { - if path == "testdata" && d.IsDir() { - return fs.SkipDir - } + walkErr := fs.WalkDir( + os.DirFS(mod.Dir), + ".", + func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } - if d.IsDir() { - return nil - } + if d.IsDir() { + if filepath.Base(path) == "testdata" { + return fs.SkipDir + } - suffix := ".go" - if testOnly { - suffix = "_test.go" - } + return nil + } - if !strings.HasSuffix(d.Name(), suffix) { - return nil - } + suffix := ".go" + if testOnly { + suffix = "_test.go" + } - files[filepath.Join(mod.Dir, path)] = struct{}{} + if !strings.HasSuffix(d.Name(), suffix) { + return nil + } - return nil - }) + files[filepath.Join(mod.Dir, path)] = struct{}{} + + return nil + }, + ) + if walkErr != nil { + return nil, walkErr + } s := make([]*ast.File, 0, len(files)) diff --git a/cmd/testo/internal/typeutil/util.go b/cmd/testo/internal/typeutil/util.go index 220c794..7605bab 100644 --- a/cmd/testo/internal/typeutil/util.go +++ b/cmd/testo/internal/typeutil/util.go @@ -5,7 +5,7 @@ import "go/types" func Format(t types.Type) string { switch t := t.(type) { case *types.Named: - return formatNamedType(t) + return formatNamed(t) case *types.Pointer: return "*" + Format(t.Elem()) @@ -15,9 +15,12 @@ func Format(t types.Type) string { } } -func formatNamedType(t *types.Named) string { - pkg := t.Obj().Pkg().Name() - name := t.Obj().Name() +func formatNamed(t *types.Named) string { + obj := t.Obj() - return pkg + "." + name + if pkg := obj.Pkg(); pkg != nil { + return pkg.Name() + "." + obj.Name() + } + + return obj.Name() } From 7f7e9e66517d03af36e8f7a8824083efcd18efd1 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 20:52:16 +0300 Subject: [PATCH 41/47] support -h without subcommand, update readme --- README.md | 2 +- cmd/testo/README.md | 22 +++++++++++++++++++++- cmd/testo/internal/cli/cli.go | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 16ffd62..0b6ba4c 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ # Testo [![Go Reference](https://pkg.go.dev/badge/github.com/ozontech/testo.svg)](https://pkg.go.dev/github.com/ozontech/testo) -[![Go Report Card](https://goreportcard.com/badge/github.com/ozontech/testo)](https://goreportcard.com/report/github.com/ozontech/testo) [![Code Coverage](https://github.com/ozontech/testo/raw/gh-pages/coverage.svg?raw=true)](https://ozontech.github.io/testo/coverage.html) [![Quality Assurance](https://github.com/ozontech/testo/actions/workflows/qa.yml/badge.svg)](https://github.com/ozontech/testo/actions/workflows/qa.yml) +[![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) Testo is a modular testing framework for Go built on top of `testing.T` with an extensive plugin system. diff --git a/cmd/testo/README.md b/cmd/testo/README.md index dd612d4..db93427 100644 --- a/cmd/testo/README.md +++ b/cmd/testo/README.md @@ -12,6 +12,26 @@ Command line tool for Testo. - Suites runner - Suites explorer +## Install + +```bash +go install github.com/ozontech/testo/cmd/testo +``` + ## Usage -Run `testo -h` to see available commands. +Run `testo -h` to see available commands: + +```txt +Usage: + testo [command] + +Available Commands: + lint Run testo linter + run Run testo suites + suites Show testo suites + tags Show project build tags + version Show testo version +``` + +Run `testo [command] -h` to show help for the given command. diff --git a/cmd/testo/internal/cli/cli.go b/cmd/testo/internal/cli/cli.go index 37f3a9f..1697a44 100644 --- a/cmd/testo/internal/cli/cli.go +++ b/cmd/testo/internal/cli/cli.go @@ -57,6 +57,13 @@ func Run() { } func run(command string, args ...string) error { + switch command { + case "-h", "-help", "--help": + usage(flag.CommandLine) + + return nil + } + r, ok := commands[command] if !ok { fmt.Fprintf(flag.CommandLine.Output(), "unknown subcommand: %q\n\n", command) From b2875466f619343f7d2ac6ed329f0b67badc5341 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 21:02:10 +0300 Subject: [PATCH 42/47] exclude cmd from coverage --- .github/workflows/qa.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 25ab9e5..ea1e3c5 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -49,7 +49,7 @@ jobs: - name: Run unit tests without race detector if: runner.os == 'Windows' run: | - go test -v -cover -coverpkg=./... ./... + go test -v -cover -coverpkg=.,./testo...,./internal/... ./... - name: Test examples output run: go test -v -tags e2e -count=1 ./examples_test.go diff --git a/Makefile b/Makefile index 5bbeb30..4c3369d 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ doc: # get test coverage coverage: - go test -coverprofile=coverage.out -coverpkg=./... ./... + go test -coverprofile=coverage.out -coverpkg=.,./testo...,./internal/... ./... go tool cover -func coverage.out # visualize test coverage From a5e615a835e70d0f47545adf84a8a3ee8ed98a5c Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 21:05:03 +0300 Subject: [PATCH 43/47] update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0b6ba4c..9316c8f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Add some flavor to your tests with - Test reflection - deeply inspect test's meta-information. - Caching - key-value storage persistent between test runs. - [Zero dependencies](./go.mod). +- [Builtin linter and suites explorer](./cmd/testo) - `testo lint` your tests. ## Why Testo From 6dfb15a73921abe3c87793f356b3590bcbbdc0be Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 21:10:29 +0300 Subject: [PATCH 44/47] update README --- CHANGELOG.md | 6 ++++++ README.md | 2 +- cmd/testo/README.md | 8 +------- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb96a3..0c04eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +## Added + +- Auxiliary command line tool for Testo featuring linter, suites explorer and runner. + ## [1.5.1] - 2026-06-17 ### Fixed diff --git a/README.md b/README.md index 9316c8f..ce27770 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Add some flavor to your tests with - Test reflection - deeply inspect test's meta-information. - Caching - key-value storage persistent between test runs. - [Zero dependencies](./go.mod). -- [Builtin linter and suites explorer](./cmd/testo) - `testo lint` your tests. +- [Auxiliary command line tool](./cmd/testo) - linter, suites explorer and runner. ## Why Testo diff --git a/cmd/testo/README.md b/cmd/testo/README.md index db93427..ae5256e 100644 --- a/cmd/testo/README.md +++ b/cmd/testo/README.md @@ -1,17 +1,11 @@ # Testo CTL -Command line tool for Testo. +Auxiliary command line tool for Testo featuring linter, suites explorer and runner. > [!WARNING] > This tool is experimental, handle with care; > may change without warning -## Features - -- Linter for common mistakes with Testo (wrong `T` type, mismatched params and more) -- Suites runner -- Suites explorer - ## Install ```bash From bcc06880bdf63e108c07aceb86d3c2c2cd4c22cb Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 22:20:56 +0300 Subject: [PATCH 45/47] add comparison with other frameworks --- COMPARISON.md | 32 ++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 COMPARISON.md diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 0000000..5f4adf0 --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,32 @@ +# Comparison with other frameworks + +> [!NOTE] +> [Testify] is mostly an assertion library, not a framework. +> Testo isn't a testify alternative and can be used with or without it. +> Though it does have its own (limited) [`suite` package](https://pkg.go.dev/github.com/stretchr/testify#readme-suite-package). + +| | [Testo] | [Ginkgo] | [GoConvey] | [Godog] | [Testify]'s suites | +| :--------------------- | :------------: | :-----------: | :--------: | :--------: | :----------------: | +| Plugins | Yes | No | No | No | No | +| Suites & hooks | Yes | Yes | Yes | Yes | Yes | +| Parametrized tests | Yes | Yes | No | Yes | No | +| Parallel tests | Yes | Partially[^1] | No[^2] | Yes | No | +| DSL[^3] | No | Yes (BDD) | Yes (BDD) | Yes (BDD) | No | +| External deps[^4] | 0 | 28 | 5 | 15 | 2 | +| Reporting | Via plugins[^5]| Yes | Yes | Yes | No | + +Testo focuses on extensibility through plugins and stays a thin layer over usual tests. +Other frameworks may be a better fit if you need BDD scenarios or some unique features "out of the box". +Testo can also support BDD-style tests through plugins. + +[^1]: Ginkgo runs parallel tests in [separate processes](https://onsi.github.io/ginkgo/#mental-model-how-ginkgo-runs-parallel-specs) with its own runner (not available through `go test`). This is _less performant_ than native `go test` parallelization based on goroutines. +[^2]: [Not supported](https://github.com/smartystreets/goconvey/issues/360). +[^3]: DSL — Domain-Specific Language. Requires describing tests in a specific way, different from usual Go tests. Not necessarily a bad thing, but has a learning curve and is less flexible. +[^4]: Total external dependencies in `go.mod` as of July 2026. Not necessarily a bad thing, but fewer dependencies mean a smaller footprint, avoiding potential vulnerabilities and slower build times. +[^5]: Any report format is achievable through plugins. See [testo-allure](https://github.com/ozontech/testo-allure). + +[Testo]: https://github.com/ozontech/testo +[Ginkgo]: https://github.com/onsi/ginkgo +[Testify]: https://github.com/stretchr/testify +[GoConvey]: https://github.com/smartystreets/goconvey +[Godog]: https://github.com/cucumber/godog diff --git a/README.md b/README.md index ce27770..74603d4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ without leaving the Go ecosystem - just a layer over `testing.T`. If your needs are outgrowing standard `testing` package, Testo is a great choice. +See [comparison with other frameworks](./COMPARISON.md). + ## Quick Start ```bash From debabc730e5060bfe13b2e7557b6c72486653570 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 12 Jul 2026 22:48:18 +0300 Subject: [PATCH 46/47] update README --- COMPARISON.md | 20 ++++++++++---------- README.md | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/COMPARISON.md b/COMPARISON.md index 5f4adf0..90618c2 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -1,19 +1,18 @@ # Comparison with other frameworks > [!NOTE] -> [Testify] is mostly an assertion library, not a framework. -> Testo isn't a testify alternative and can be used with or without it. -> Though it does have its own (limited) [`suite` package](https://pkg.go.dev/github.com/stretchr/testify#readme-suite-package). +> [Testify] is mostly known as an assertion library, not a framework. +> Testo isn't a testify's `require` & `assert` alternative and can be used with or without it. | | [Testo] | [Ginkgo] | [GoConvey] | [Godog] | [Testify]'s suites | | :--------------------- | :------------: | :-----------: | :--------: | :--------: | :----------------: | | Plugins | Yes | No | No | No | No | | Suites & hooks | Yes | Yes | Yes | Yes | Yes | | Parametrized tests | Yes | Yes | No | Yes | No | -| Parallel tests | Yes | Partially[^1] | No[^2] | Yes | No | -| DSL[^3] | No | Yes (BDD) | Yes (BDD) | Yes (BDD) | No | -| External deps[^4] | 0 | 28 | 5 | 15 | 2 | -| Reporting | Via plugins[^5]| Yes | Yes | Yes | No | +| Parallel tests | Yes | Partially[^1] | No[^2] | Yes | No[^3] | +| DSL[^4] | No | Yes (BDD) | Yes (BDD) | Yes (BDD) | No | +| External deps[^5] | 0 | 28 | 5 | 15 | 2 | +| Reporting | Via plugins[^6]| Yes | Yes | Yes | No | Testo focuses on extensibility through plugins and stays a thin layer over usual tests. Other frameworks may be a better fit if you need BDD scenarios or some unique features "out of the box". @@ -21,9 +20,10 @@ Testo can also support BDD-style tests through plugins. [^1]: Ginkgo runs parallel tests in [separate processes](https://onsi.github.io/ginkgo/#mental-model-how-ginkgo-runs-parallel-specs) with its own runner (not available through `go test`). This is _less performant_ than native `go test` parallelization based on goroutines. [^2]: [Not supported](https://github.com/smartystreets/goconvey/issues/360). -[^3]: DSL — Domain-Specific Language. Requires describing tests in a specific way, different from usual Go tests. Not necessarily a bad thing, but has a learning curve and is less flexible. -[^4]: Total external dependencies in `go.mod` as of July 2026. Not necessarily a bad thing, but fewer dependencies mean a smaller footprint, avoiding potential vulnerabilities and slower build times. -[^5]: Any report format is achievable through plugins. See [testo-allure](https://github.com/ozontech/testo-allure). +[^3]: See [issue #934](https://github.com/stretchr/testify/issues/934). +[^4]: DSL — Domain-Specific Language. Requires describing tests in a specific way, different from usual Go tests. Not necessarily a bad thing, but has a learning curve and is less flexible. +[^5]: Total external dependencies in `go.mod` as of July 2026. Not necessarily a bad thing, but fewer dependencies mean a smaller footprint, avoiding potential vulnerabilities and slower build times. +[^6]: Any report format is achievable through plugins. See [testo-allure](https://github.com/ozontech/testo-allure). [Testo]: https://github.com/ozontech/testo [Ginkgo]: https://github.com/onsi/ginkgo diff --git a/README.md b/README.md index 74603d4..58a1d1a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,25 @@ Makes it easier to run and debug individual suite tests and adds helpful snippet ![VSCode extension screenshot showing codelens buttons for running and debugging a test](./vscode-extension/example.png) +## Testo command line tool + +Testo has an auxiliary command line tool featuring linter, suites explorer and runner. + +[See more here](./cmd/testo). + +Example: + +```bash +go install github.com/ozontech/testo/cmd/testo + +testo lint ./... +testo run mypkg/Functional.TestFoo +testo suites -f "{{ .Package }}/{{ .Suite }}" | fzf +``` + +> [!NOTE] +> This command line tool is _completely optional_ and _is not required_ to run tests. + ## Minimum supported Go version Testo guarantees to support at least **3 latest major** [Go releases](https://go.dev/doc/devel/release). From 59a2339d0b0514861f8462ef2b3216baf1dd2668 Mon Sep 17 00:00:00 2001 From: metafates Date: Tue, 14 Jul 2026 15:25:29 +0300 Subject: [PATCH 47/47] improve suites template --- cmd/testo/internal/cli/flag.go | 14 ++++++- cmd/testo/internal/cmd/cmdlint/lint.go | 7 +++- cmd/testo/internal/cmd/cmdrun/run.go | 7 +++- cmd/testo/internal/cmd/cmdsuites/suites.go | 39 ++++++++++++++++---- cmd/testo/internal/cmd/cmdsuites/template.go | 33 +++++++++++++---- cmd/testo/internal/cmd/cmdtags/tags.go | 7 +++- 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/cmd/testo/internal/cli/flag.go b/cmd/testo/internal/cli/flag.go index 53265b8..5c20bb8 100644 --- a/cmd/testo/internal/cli/flag.go +++ b/cmd/testo/internal/cli/flag.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "flag" "text/template" ) @@ -24,7 +25,18 @@ func (f *FlagTemplate) Execute(v any) (string, error) { } func (f *FlagTemplate) Set(s string) error { - parsed, err := template.New("flag").Parse(s) + funcs := template.FuncMap{ + "json": func(v any) string { + m, err := json.Marshal(v) + if err != nil { + return err.Error() + } + + return string(m) + }, + } + + parsed, err := template.New("flag").Funcs(funcs).Parse(s) if err != nil { return err } diff --git a/cmd/testo/internal/cmd/cmdlint/lint.go b/cmd/testo/internal/cmd/cmdlint/lint.go index 6c8be98..1d09b0a 100644 --- a/cmd/testo/internal/cmd/cmdlint/lint.go +++ b/cmd/testo/internal/cmd/cmdlint/lint.go @@ -12,7 +12,12 @@ import ( func init() { cli.Add("lint", func(f *flag.FlagSet, c *Cmd) { - f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.BoolVar(&c.Load.Strict, "strict", false, "enable strict mode") f.BoolVar(&c.JSON, "json", false, "output json") diff --git a/cmd/testo/internal/cmd/cmdrun/run.go b/cmd/testo/internal/cmd/cmdrun/run.go index 855cac8..adf8775 100644 --- a/cmd/testo/internal/cmd/cmdrun/run.go +++ b/cmd/testo/internal/cmd/cmdrun/run.go @@ -19,7 +19,12 @@ import ( func init() { cli.Add("run", func(f *flag.FlagSet, c *Cmd) { - f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.BoolVar(&c.N, "n", false, "print the commands but do not run them") f.BoolVar(&c.Verbose, "v", false, "verbose output") diff --git a/cmd/testo/internal/cmd/cmdsuites/suites.go b/cmd/testo/internal/cmd/cmdsuites/suites.go index f7c29a7..7c8b433 100644 --- a/cmd/testo/internal/cmd/cmdsuites/suites.go +++ b/cmd/testo/internal/cmd/cmdsuites/suites.go @@ -15,7 +15,12 @@ func init() { cli.Add("suites", func(f *flag.FlagSet, c *Cmd) { c.Format.Set("{{ .Package }}/{{ .Suite }}") - f.StringVar(&c.Load.Tags, "tags", "", "build tags separated by comma, derived from source if empty") + f.StringVar( + &c.Load.Tags, + "tags", + "", + "build tags separated by comma, derived from source if empty", + ) f.StringVar(&c.Load.Testo, "testo", cmd.DefaultTesto, "testo package") f.Var(&c.Format, "f", "output format") f.BoolVar(&c.Nul, "0", false, "output each line delimited by NUL byte") @@ -32,6 +37,10 @@ Examples: pick suite test with fzf and bat preview testo suites ./... -0 -f '{{ .Package }}/{{ .Suite }}.{{ .Test }} {{ .Test.Pos.Path }} {{ .Test.Pos.Line }}' | fzf --read0 --delimiter " " --with-nth 1 --preview 'bat -Ss --color always --plain --tabs 4 --line-range {3}:+$FZF_PREVIEW_LINES {2}' --accept-nth 1 --preview-window up + + output as json and filter with jq + + testo suites ./... -f '{{ json .Test }}' | jq '. | select(.Parametrized)' `), ) } @@ -71,29 +80,43 @@ func (c Cmd) printSuite(suite loader.Suite, seen map[string]bool) error { } } + newParams := func(ps []loader.Parameter) []Parameter { + s := make([]Parameter, 0, len(ps)) + + for _, p := range ps { + s = append(s, Parameter{Name: p.Name}) + } + + return s + } + data := Data{ Package: Package{ Name: suite.Package.Name, Path: suite.Package.Path, Dir: suite.Package.Dir, }, - Suite: Entity{ + Suite: Suite{ Name: suite.Name, Pos: newPos(suite.FSet.Position(suite.Pos)), }, } for _, t := range suite.Tests { - data.Tests = append(data.Tests, Entity{ - Name: t.Name, - Pos: newPos(suite.FSet.Position(t.Pos)), + data.Tests = append(data.Tests, Test{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + Parametrized: t.Parametrized, + Parameters: newParams(t.Parameters), }) } for _, t := range suite.Tests { - data.Test = Entity{ - Name: t.Name, - Pos: newPos(suite.FSet.Position(t.Pos)), + data.Test = Test{ + Name: t.Name, + Pos: newPos(suite.FSet.Position(t.Pos)), + Parametrized: t.Parametrized, + Parameters: newParams(t.Parameters), } line, err := c.Format.Execute(data) diff --git a/cmd/testo/internal/cmd/cmdsuites/template.go b/cmd/testo/internal/cmd/cmdsuites/template.go index c81963f..51b8fb0 100644 --- a/cmd/testo/internal/cmd/cmdsuites/template.go +++ b/cmd/testo/internal/cmd/cmdsuites/template.go @@ -13,10 +13,10 @@ var currentFile string //types:start type Data struct { - Package Package // suite package - Suite Entity // current suite - Test Entity // current test for this suite - Tests []Entity // all tests + Package Package // suite package + Suite Suite // current suite + Test Test // current test for this suite + Tests []Test // all tests } type Package struct { @@ -25,11 +25,22 @@ type Package struct { Dir string } -type Entity struct { +type Suite struct { Name string Pos Pos } +type Test struct { + Name string + Pos Pos + Parametrized bool + Parameters []Parameter +} + +type Parameter struct { + Name string +} + type Pos struct { Dir string // file dir Filename string // base file name @@ -40,12 +51,20 @@ type Pos struct { //types:end +func (p Parameter) String() string { + return p.Name +} + func (p Package) String() string { return p.Name } -func (e Entity) String() string { - return e.Name +func (s Suite) String() string { + return s.Name +} + +func (t Test) String() string { + return t.Name } func (p Pos) String() string { diff --git a/cmd/testo/internal/cmd/cmdtags/tags.go b/cmd/testo/internal/cmd/cmdtags/tags.go index 37d3511..2c1827b 100644 --- a/cmd/testo/internal/cmd/cmdtags/tags.go +++ b/cmd/testo/internal/cmd/cmdtags/tags.go @@ -15,7 +15,12 @@ import ( func init() { cli.Add("tags", func(f *flag.FlagSet, cmd *Cmd) { f.BoolVar(&cmd.Tests, "tests", false, "only show build tags used in *_test.go files") - f.BoolVar(&cmd.All, "a", false, "include build tags cancelled by negations, e.g. //go:build !tag") + f.BoolVar( + &cmd.All, + "a", + false, + "include build tags cancelled by negations, e.g. //go:build !tag", + ) }, cli.WithShort("Show project build tags"), cli.WithLong(`Show project build tags.