diff --git a/.github/actions/extension-check/action.yml b/.github/actions/extension-check/action.yml new file mode 100644 index 00000000..ed3035be --- /dev/null +++ b/.github/actions/extension-check/action.yml @@ -0,0 +1,26 @@ +name: "Extension Check" +description: "Lint, typecheck, test, and compile the VS Code extension" + +runs: + using: "composite" + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: vscode-gotest/package-lock.json + - run: npm ci + shell: bash + working-directory: vscode-gotest + - run: npm run format:check + shell: bash + working-directory: vscode-gotest + - run: npx tsc --noEmit + shell: bash + working-directory: vscode-gotest + - run: npm test + shell: bash + working-directory: vscode-gotest + - run: npm run compile + shell: bash + working-directory: vscode-gotest diff --git a/.github/actions/setup-gotest/action.yml b/.github/actions/setup-gotest/action.yml deleted file mode 100644 index 252a8023..00000000 --- a/.github/actions/setup-gotest/action.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Setup gotest" -description: "Install the gotest CLI for Go test suite management" - -inputs: - version: - description: "Version tag (e.g. v1.0.0) or 'latest'" - required: false - default: "latest" - -runs: - using: "composite" - steps: - - name: Install gotest - shell: bash - run: go install github.com/mvrahden/go-test/cmd/gotest@${{ inputs.version }} diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index d7a144bc..7d0417ed 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -28,16 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: vscode-gotest/package-lock.json - - run: npm ci - - run: npm run format:check - - run: npx tsc --noEmit - - run: npm test - - run: npm run compile + - uses: ./.github/actions/extension-check package: runs-on: ubuntu-latest diff --git a/.github/workflows/extension.yml b/.github/workflows/extension.yml index 0db980b6..4ee7dbed 100644 --- a/.github/workflows/extension.yml +++ b/.github/workflows/extension.yml @@ -20,16 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: vscode-gotest/package-lock.json - - run: npm ci - - run: npm run format:check - - run: npx tsc --noEmit - - run: npm test - - run: npm run compile + - uses: ./.github/actions/extension-check package: runs-on: ubuntu-latest diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 18d50de1..6b4590ad 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -17,8 +17,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.26" - - name: Init workspace - run: go work init . ./examples - name: Format check run: test -z "$(gofmt -l .)" || (echo "gofmt needed on:" && gofmt -l . && exit 1) - name: Vet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01ab8190..d4022ea9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: stable - - name: Init workspace - run: go work init . ./examples - run: go vet ./... ./examples/... - run: go test -ldflags=-checklinkname=0 ./... -race - run: go run ./cmd/gotest spec --no-color --min=40 ./... ./examples/... -race diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4531c53..12fb3c9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,19 +10,6 @@ concurrency: cancel-in-progress: true jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.26" - - run: go build ./... - platform-test: runs-on: ${{ matrix.os }} strategy: @@ -34,8 +21,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.26" - - name: Init workspace - run: go work init . ./examples - name: Test (stdlib) run: go test -ldflags=-checklinkname=0 ./... -race - name: Test (suites) @@ -55,8 +40,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - - name: Init workspace - run: go work init . ./examples - name: Test (stdlib) run: go test -ldflags=-checklinkname=0 -coverprofile=coverage-stdlib.out ./... -race - name: Test (suites) diff --git a/.gitignore b/.gitignore index 29ea1bde..34b70914 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -go.work go.work.sum bin/ dist/ diff --git a/cmd/gotest/cli.go b/cmd/gotest/cli.go index 53e04bb5..8e8b1c17 100644 --- a/cmd/gotest/cli.go +++ b/cmd/gotest/cli.go @@ -81,13 +81,11 @@ func runTest(inv Invocation) int { //nolint:gocritic // hugeParam: stable API return runSpec(Invocation{Args: specArgs, Config: inv.Config}) } - args := inv.DefaultArgs() - ownArgs, goTestArgs, err := SplitArgs(args, testAllowed) + ownArgs, goTestArgs, err := SplitArgs(inv.DefaultArgs(), testAllowed) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - jsonMode, goTestArgs := stripJSONFlag(goTestArgs) minCoverage, err := parseMinFlag(ownArgs) @@ -98,84 +96,22 @@ func runTest(inv Invocation) int { //nolint:gocritic // hugeParam: stable API if minCoverage == 0 { minCoverage = inv.Config.MinCoverage } - setupTimeout, err := parseSetupTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout, err := parseGlobalTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout = resolveGlobalTimeout(globalTimeout) - parallel, err := parseParallelFlag(ownArgs) + + goTestArgs, coverProfile, coverCleanup, err := ensureCoverProfile(goTestArgs, minCoverage) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if parallel == 0 { - parallel = inv.Config.Parallel - } - compileParallel, err := parseCompileParallelFlag(ownArgs) + defer coverCleanup() + + cfg, err := parseExecFlags(ownArgs, goTestArgs, &inv.Config) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if compileParallel == 0 { - compileParallel = inv.Config.CompileParallel - } - - var coverProfile string - if minCoverage > 0 { - for _, arg := range goTestArgs { - if v, ok := strings.CutPrefix(arg, "-coverprofile="); ok { - coverProfile = v - } - } - if coverProfile == "" { - f, err := os.CreateTemp("", "gotest-cover-*.out") - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - coverProfile = f.Name() - f.Close() - defer os.Remove(coverProfile) - goTestArgs = append(goTestArgs, "-coverprofile="+coverProfile) - } - } - - patterns := ExtractPackagePatterns(goTestArgs) - cfg := ExecConfig{ - GoTestArgs: goTestArgs, - PackagePatterns: patterns, - SetupTimeout: setupTimeout, - GlobalTimeout: globalTimeout, - Debug: slices.Contains(ownArgs, "--debug"), - CI: slices.Contains(ownArgs, "--ci"), - JSON: jsonMode, - UpdateSnapshots: slices.Contains(ownArgs, "--update-snapshots"), - NoCache: slices.Contains(ownArgs, "--no-cache"), - Parallel: parallel, - CompileParallel: compileParallel, - } - - code := Run(cfg) + cfg.JSON = jsonMode - if code == 0 && minCoverage > 0 { - pct, err := readCoverageTotal(coverProfile) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: reading coverage: %s\n", err) - return 2 - } - if pct < float64(minCoverage) { - fmt.Fprintf(os.Stderr, "\nFAIL: %.1f%% coverage (minimum %d%%)\n", pct, minCoverage) - return 1 - } - } - - return code + return enforceCoverage(coverProfile, minCoverage, Run(cfg)) } func parseMinFlag(args []string) (int, error) { @@ -252,6 +188,79 @@ func parseCompileParallelFlag(args []string) (int, error) { return v, nil } +func parseExecFlags(ownArgs, goTestArgs []string, projCfg *config.ProjectConfig) (ExecConfig, error) { + setupTimeout, err := parseSetupTimeoutFlag(ownArgs) + if err != nil { + return ExecConfig{}, err + } + globalTimeout, err := parseGlobalTimeoutFlag(ownArgs) + if err != nil { + return ExecConfig{}, err + } + globalTimeout = resolveGlobalTimeout(globalTimeout) + parallel, err := parseParallelFlag(ownArgs) + if err != nil { + return ExecConfig{}, err + } + if parallel == 0 { + parallel = projCfg.Parallel + } + compileParallel, err := parseCompileParallelFlag(ownArgs) + if err != nil { + return ExecConfig{}, err + } + if compileParallel == 0 { + compileParallel = projCfg.CompileParallel + } + return ExecConfig{ + GoTestArgs: goTestArgs, + PackagePatterns: ExtractPackagePatterns(goTestArgs), + SetupTimeout: setupTimeout, + GlobalTimeout: globalTimeout, + Debug: slices.Contains(ownArgs, "--debug"), + CI: slices.Contains(ownArgs, "--ci"), + UpdateSnapshots: slices.Contains(ownArgs, "--update-snapshots"), + NoCache: slices.Contains(ownArgs, "--no-cache"), + Parallel: parallel, + CompileParallel: compileParallel, + }, nil +} + +func ensureCoverProfile(goTestArgs []string, minCoverage int) ([]string, string, func(), error) { + noop := func() {} + if minCoverage <= 0 { + return goTestArgs, "", noop, nil + } + for _, arg := range goTestArgs { + if v, ok := strings.CutPrefix(arg, "-coverprofile="); ok { + return goTestArgs, v, noop, nil + } + } + f, err := os.CreateTemp("", "gotest-cover-*.out") + if err != nil { + return goTestArgs, "", noop, err + } + profile := f.Name() + f.Close() + return append(goTestArgs, "-coverprofile="+profile), profile, func() { os.Remove(profile) }, nil +} + +func enforceCoverage(coverProfile string, minCoverage, code int) int { + if code != 0 || minCoverage <= 0 || coverProfile == "" { + return code + } + pct, err := readCoverageTotal(coverProfile) + if err != nil { + fmt.Fprintf(os.Stderr, "FAIL: reading coverage: %s\n", err) + return 2 + } + if pct < float64(minCoverage) { + fmt.Fprintf(os.Stderr, "\nFAIL: %.1f%% coverage (minimum %d%%)\n", pct, minCoverage) + return 1 + } + return code +} + func readCoverageTotal(profilePath string) (float64, error) { out, err := exec.Command("go", "tool", "cover", "-func="+profilePath).Output() //nolint:gosec // G204: go tool with controlled arguments if err != nil { diff --git a/cmd/gotest/spec.go b/cmd/gotest/spec.go index 3301c2cc..98a284fc 100644 --- a/cmd/gotest/spec.go +++ b/cmd/gotest/spec.go @@ -7,8 +7,6 @@ import ( "io" "os" "os/signal" - "slices" - "strings" "github.com/mvrahden/go-test/internal/gotestgen" "github.com/mvrahden/go-test/internal/gotestrunner" @@ -31,78 +29,28 @@ func runSpec(inv Invocation) int { //nolint:gocritic // hugeParam: stable API return runSpecFromInput(input, format, output, noColor) } - setupTimeout, err := parseSetupTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout, err := parseGlobalTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout = resolveGlobalTimeout(globalTimeout) minCoverage, err := parseMinFlag(ownArgs) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - parallel, err := parseParallelFlag(ownArgs) + goTestArgs, coverProfile, coverCleanup, err := ensureCoverProfile(goTestArgs, minCoverage) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if parallel == 0 { - parallel = inv.Config.Parallel - } - compileParallel, err := parseCompileParallelFlag(ownArgs) + defer coverCleanup() + + cfg, err := parseExecFlags(ownArgs, goTestArgs, &inv.Config) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if compileParallel == 0 { - compileParallel = inv.Config.CompileParallel - } - - var coverProfile string - if minCoverage > 0 { - for _, arg := range goTestArgs { - if v, ok := strings.CutPrefix(arg, "-coverprofile="); ok { - coverProfile = v - } - } - if coverProfile == "" { - f, ferr := os.CreateTemp("", "gotest-cover-*.out") - if ferr != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", ferr) - return 2 - } - coverProfile = f.Name() - f.Close() - defer os.Remove(coverProfile) - goTestArgs = append(goTestArgs, "-coverprofile="+coverProfile) - } - } - - patterns := ExtractPackagePatterns(goTestArgs) - - cfg := ExecConfig{ - GoTestArgs: goTestArgs, - PackagePatterns: patterns, - SetupTimeout: setupTimeout, - GlobalTimeout: globalTimeout, - Debug: slices.Contains(ownArgs, "--debug"), - CI: slices.Contains(ownArgs, "--ci"), - UpdateSnapshots: slices.Contains(ownArgs, "--update-snapshots"), - NoCache: slices.Contains(ownArgs, "--no-cache"), - Parallel: parallel, - CompileParallel: compileParallel, - } classified := gotestrunner.ClassifyGoTestArgs(goTestArgs) loadFlags := gotestrunner.StripCoverBuildFlags(classified.BuildFlags) - loaded, err := gotestgen.LoadPackages(patterns, loadFlags) + loaded, err := gotestgen.LoadPackages(cfg.PackagePatterns, loadFlags) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 @@ -189,19 +137,7 @@ func runSpec(inv Invocation) int { //nolint:gocritic // hugeParam: stable API gotestspec.RenderTerminal(w, tree, renderOpts...) } - if code == 0 && minCoverage > 0 && coverProfile != "" { - pct, err := readCoverageTotal(coverProfile) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: reading coverage: %s\n", err) - return 2 - } - if pct < float64(minCoverage) { - fmt.Fprintf(os.Stderr, "\nFAIL: %.1f%% coverage (minimum %d%%)\n", pct, minCoverage) - return 1 - } - } - - return code + return enforceCoverage(coverProfile, minCoverage, code) } func runSpecFromInput(input, format, output string, noColor bool) int { diff --git a/cmd/gotest/summary.go b/cmd/gotest/summary.go index afa6765e..08d23d5c 100644 --- a/cmd/gotest/summary.go +++ b/cmd/gotest/summary.go @@ -7,7 +7,6 @@ import ( "io" "os" "os/signal" - "slices" "strings" "time" @@ -34,17 +33,6 @@ func runSummary(inv Invocation) int { //nolint:gocritic // hugeParam: stable API return runSummaryFromInput(input, format, output, coverageProfile, noColor, github) } - setupTimeout, err := parseSetupTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout, err := parseGlobalTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout = resolveGlobalTimeout(globalTimeout) minCoverage, err := parseMinFlag(ownArgs) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) @@ -54,61 +42,22 @@ func runSummary(inv Invocation) int { //nolint:gocritic // hugeParam: stable API minCoverage = inv.Config.MinCoverage } - parallel, err := parseParallelFlag(ownArgs) + goTestArgs, coverProfile, coverCleanup, err := ensureCoverProfile(goTestArgs, minCoverage) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if parallel == 0 { - parallel = inv.Config.Parallel - } - compileParallel, err := parseCompileParallelFlag(ownArgs) + defer coverCleanup() + + cfg, err := parseExecFlags(ownArgs, goTestArgs, &inv.Config) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if compileParallel == 0 { - compileParallel = inv.Config.CompileParallel - } - - var coverProfile string - if minCoverage > 0 { - for _, arg := range goTestArgs { - if v, ok := strings.CutPrefix(arg, "-coverprofile="); ok { - coverProfile = v - } - } - if coverProfile == "" { - f, ferr := os.CreateTemp("", "gotest-cover-*.out") - if ferr != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", ferr) - return 2 - } - coverProfile = f.Name() - f.Close() - defer os.Remove(coverProfile) - goTestArgs = append(goTestArgs, "-coverprofile="+coverProfile) - } - } - - patterns := ExtractPackagePatterns(goTestArgs) - - cfg := ExecConfig{ - GoTestArgs: goTestArgs, - PackagePatterns: patterns, - SetupTimeout: setupTimeout, - GlobalTimeout: globalTimeout, - Debug: slices.Contains(ownArgs, "--debug"), - CI: slices.Contains(ownArgs, "--ci"), - UpdateSnapshots: slices.Contains(ownArgs, "--update-snapshots"), - NoCache: slices.Contains(ownArgs, "--no-cache"), - Parallel: parallel, - CompileParallel: compileParallel, - } classified := gotestrunner.ClassifyGoTestArgs(goTestArgs) loadFlags := gotestrunner.StripCoverBuildFlags(classified.BuildFlags) - loaded, err := gotestgen.LoadPackages(patterns, loadFlags) + loaded, err := gotestgen.LoadPackages(cfg.PackagePatterns, loadFlags) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 @@ -182,19 +131,7 @@ func runSummary(inv Invocation) int { //nolint:gocritic // hugeParam: stable API elapsed := time.Since(pipelineStart) writeSummaryOutput(tree, format, output, coverageProfile, noColor, github, elapsed) - if code == 0 && minCoverage > 0 && coverProfile != "" { - pct, err := readCoverageTotal(coverProfile) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: reading coverage: %s\n", err) - return 2 - } - if pct < float64(minCoverage) { - fmt.Fprintf(os.Stderr, "\nFAIL: %.1f%% coverage (minimum %d%%)\n", pct, minCoverage) - return 1 - } - } - - return code + return enforceCoverage(coverProfile, minCoverage, code) } func runSummaryFromInput(input, format, output, coverageProfile string, noColor, github bool) int { diff --git a/cmd/gotest/watch.go b/cmd/gotest/watch.go index dceeb957..753a46cf 100644 --- a/cmd/gotest/watch.go +++ b/cmd/gotest/watch.go @@ -7,7 +7,6 @@ import ( "os" "os/signal" "path/filepath" - "slices" "strings" "time" @@ -49,52 +48,17 @@ func runWatch(inv Invocation) int { //nolint:gocritic // hugeParam: stable API return 2 } jsonMode, goTestArgs := stripJSONFlag(goTestArgs) - setupTimeout, err := parseSetupTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout, err := parseGlobalTimeoutFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - globalTimeout = resolveGlobalTimeout(globalTimeout) debounceDuration, err := parseDebounceFlag(ownArgs) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - parallel, err := parseParallelFlag(ownArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) - return 2 - } - if parallel == 0 { - parallel = inv.Config.Parallel - } - compileParallel, err := parseCompileParallelFlag(ownArgs) + + cfg, err := parseExecFlags(ownArgs, goTestArgs, &inv.Config) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: %s\n", err) return 2 } - if compileParallel == 0 { - compileParallel = inv.Config.CompileParallel - } - patterns := ExtractPackagePatterns(goTestArgs) - - cfg := ExecConfig{ - GoTestArgs: goTestArgs, - PackagePatterns: patterns, - SetupTimeout: setupTimeout, - GlobalTimeout: globalTimeout, - Debug: slices.Contains(ownArgs, "--debug"), - CI: slices.Contains(ownArgs, "--ci"), - UpdateSnapshots: slices.Contains(ownArgs, "--update-snapshots"), - NoCache: slices.Contains(ownArgs, "--no-cache"), - Parallel: parallel, - CompileParallel: compileParallel, - } ctx, stop := signal.NotifyContext(context.Background(), shutdownSignals...) defer stop() @@ -114,7 +78,7 @@ func runWatch(inv Invocation) int { //nolint:gocritic // hugeParam: stable API } defer watcher.Close() - for _, pattern := range patterns { + for _, pattern := range cfg.PackagePatterns { addWatchDirs(watcher, pattern) } diff --git a/go.work b/go.work new file mode 100644 index 00000000..8db38d5f --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.24.0 + +use ( + . + ./examples +) diff --git a/internal/gotestast/fixture.go b/internal/gotestast/fixture.go index 4f83e944..2ffe418e 100644 --- a/internal/gotestast/fixture.go +++ b/internal/gotestast/fixture.go @@ -7,6 +7,7 @@ import ( "go/types" "strings" + "github.com/mvrahden/go-test/internal/protocol" "golang.org/x/tools/go/packages" ) @@ -20,8 +21,8 @@ const ( ) const ( - suffixFixture = "Fixture" - suffixSharedFixture = "SharedFixture" + suffixFixture = protocol.SuffixFixture + suffixSharedFixture = protocol.SuffixSharedFixture ) // FixtureSpec describes a fixture type identified by naming convention. @@ -293,6 +294,27 @@ func ValidateFixtureConsistency(f *FixtureSpec) (token.Pos, error) { return -1, nil } +// ReceiverTypeName extracts the type name from a receiver expression, +// unwrapping pointer and generic type wrappers. +func ReceiverTypeName(expr ast.Expr) string { + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + switch x := expr.(type) { + case *ast.Ident: + return x.Name + case *ast.IndexExpr: + if ident, ok := x.X.(*ast.Ident); ok { + return ident.Name + } + case *ast.IndexListExpr: + if ident, ok := x.X.(*ast.Ident); ok { + return ident.Name + } + } + return "" +} + func validateContextErrorSig(sig *types.Signature, methodID string) error { if sig.Params().Len() != 1 || sig.Results().Len() != 1 { return fmt.Errorf("unsupported signature for %q: expected (context.Context) error", methodID) diff --git a/internal/gotestast/hydrate.go b/internal/gotestast/hydrate.go index 44462757..006c52e0 100644 --- a/internal/gotestast/hydrate.go +++ b/internal/gotestast/hydrate.go @@ -3,6 +3,8 @@ package gotestast import ( "go/ast" "go/types" + + "golang.org/x/tools/go/packages" ) // ClassifyLocalFields analyzes a Hydrate method's AST to determine which @@ -106,6 +108,38 @@ func collectReceiverMethodCalls(block *ast.BlockStmt, recvName string) []string return names } +// FindMethodDecl finds the FuncDecl for a named method on a receiver type +// within the given package's syntax. +func FindMethodDecl(pkg *packages.Package, receiverName, methodName string) *ast.FuncDecl { + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Recv == nil || fd.Name.Name != methodName { + continue + } + obj := pkg.TypesInfo.ObjectOf(fd.Name) + fn, ok := obj.(*types.Func) + if !ok { + continue + } + sig, ok := fn.Type().(*types.Signature) + if !ok || sig.Recv() == nil { + continue + } + recv := sig.Recv().Type() + if ptr, ok := recv.(*types.Pointer); ok { + recv = ptr.Elem() + } + named, ok := recv.(*types.Named) + if !ok || named.Obj().Name() != receiverName { + continue + } + return fd + } + } + return nil +} + func findMethodBodyInSyntax(syntax []*ast.File, info *types.Info, fixtureName, methodName string) *ast.BlockStmt { for _, file := range syntax { for _, decl := range file.Decls { diff --git a/internal/gotestgen/resolver.go b/internal/gotestgen/resolver.go index e004b719..5b361d28 100644 --- a/internal/gotestgen/resolver.go +++ b/internal/gotestgen/resolver.go @@ -2,13 +2,13 @@ package gotestgen import ( "fmt" - "go/ast" "go/types" "os" "sort" "strings" "github.com/mvrahden/go-test/internal/gotestast" + "github.com/mvrahden/go-test/internal/protocol" "golang.org/x/tools/go/packages" ) @@ -263,7 +263,7 @@ func (r *resolver) resolveFixturesForSuite(suite *gotestast.TestSuiteSpec) ([]su } name := named.Obj().Name() - if strings.HasSuffix(name, "SharedFixture") { + if strings.HasSuffix(name, protocol.SuffixSharedFixture) { ref, err := r.buildSharedFixtureRef(named, sfIdx) if err != nil { return nil, err @@ -271,7 +271,7 @@ func (r *resolver) resolveFixturesForSuite(suite *gotestast.TestSuiteSpec) ([]su ref.FieldName = field.Name() sharedRefs = append(sharedRefs, ref) sfIdx++ - } else if strings.HasSuffix(name, "Fixture") { + } else if strings.HasSuffix(name, protocol.SuffixFixture) { rf, err := r.resolveFixture(named) if err != nil { return nil, err @@ -312,7 +312,7 @@ func (r *resolver) resolveFixture(named *types.Named) (*ResolvedFixture, error) } kind := gotestast.PackageFixture - if strings.HasSuffix(baseName, "SharedFixture") { + if strings.HasSuffix(baseName, protocol.SuffixSharedFixture) { kind = gotestast.SharedFixture } @@ -384,7 +384,7 @@ func (r *resolver) resolvePackageFixtureFields(rf *ResolvedFixture, st *types.St } typeName := named.Obj().Name() - if strings.HasSuffix(typeName, "SharedFixture") { + if strings.HasSuffix(typeName, protocol.SuffixSharedFixture) { sfRef, err := r.buildSharedFixtureRef(named, sfIdx) if err != nil { return err @@ -392,7 +392,7 @@ func (r *resolver) resolvePackageFixtureFields(rf *ResolvedFixture, st *types.St sfRef.FieldName = field.Name() rf.SharedFixtures = append(rf.SharedFixtures, sfRef) sfIdx++ - } else if strings.HasSuffix(typeName, "Fixture") { + } else if strings.HasSuffix(typeName, protocol.SuffixFixture) { parent, err := r.resolveFixture(named) if err != nil { return err @@ -522,7 +522,7 @@ func (r *resolver) registerSharedFixture(named *types.Named) error { if hasHydrate { pkg := r.findPackageForType(named) if pkg != nil && len(pkg.Syntax) > 0 { - hydrateDecl := findHydrateDecl(pkg, baseName) + hydrateDecl := gotestast.FindMethodDecl(pkg, baseName, "Hydrate") if hydrateDecl != nil { localFields = gotestast.ClassifyLocalFieldsRaw(hydrateDecl, baseName, pkg.Syntax, pkg.TypesInfo) } @@ -549,7 +549,7 @@ func (r *resolver) registerSharedFixture(named *types.Named) error { continue } depName := depNamed.Obj().Name() - if strings.HasSuffix(depName, "SharedFixture") { + if strings.HasSuffix(depName, protocol.SuffixSharedFixture) { depKey := depNamed.Obj().Pkg().Path() + "." + fixtureIdentifier(depNamed) deps = append(deps, depKey) depFields[f.Name()] = true @@ -637,36 +637,6 @@ func (r *resolver) findLocalSpec(rf *ResolvedFixture) *gotestast.FixtureSpec { return nil } -func findHydrateDecl(pkg *packages.Package, fixtureName string) *ast.FuncDecl { - for _, file := range pkg.Syntax { - for _, decl := range file.Decls { - fd, ok := decl.(*ast.FuncDecl) - if !ok || fd.Recv == nil || fd.Name.Name != "Hydrate" { - continue - } - obj := pkg.TypesInfo.ObjectOf(fd.Name) - fn, ok := obj.(*types.Func) - if !ok { - continue - } - sig, ok := fn.Type().(*types.Signature) - if !ok || sig.Recv() == nil { - continue - } - recv := sig.Recv().Type() - if ptr, ok := recv.(*types.Pointer); ok { - recv = ptr.Elem() - } - named, ok := recv.(*types.Named) - if !ok || named.Obj().Name() != fixtureName { - continue - } - return fd - } - } - return nil -} - func validateTransferFieldType(fixtureName string, field *types.Var) error { if reason := nonSerializable(field.Type()); reason != "" { return fmt.Errorf("shared fixture %q: transfer field %q has non-JSON-serializable type %s (%s)", fixtureName, field.Name(), field.Type(), reason) diff --git a/internal/gotestgen/utils.go b/internal/gotestgen/utils.go index 38b73f0a..d9bf9c9b 100644 --- a/internal/gotestgen/utils.go +++ b/internal/gotestgen/utils.go @@ -12,6 +12,9 @@ func DeterminePkgDir(p *packages.Package) string { if len(p.GoFiles) > 0 { return filepath.Dir(p.GoFiles[0]) } + if len(p.CompiledGoFiles) > 0 { + return filepath.Dir(p.CompiledGoFiles[0]) + } return "" } modDir := p.Module.Dir diff --git a/internal/gotestrunner/args.go b/internal/gotestrunner/args.go index 8756c745..5b79dc9e 100644 --- a/internal/gotestrunner/args.go +++ b/internal/gotestrunner/args.go @@ -206,35 +206,35 @@ func TranslateToTestBinaryFlags(flags []string) []string { return out } -// ExtractRunFilter returns the value of -run from run flags, if present. -func ExtractRunFilter(runFlags []string) string { - for i, f := range runFlags { +// extractFlag returns the value of a named flag from flags, stopping at -args. +func extractFlag(flags []string, name string) string { + for i, f := range flags { if f == "-args" { return "" } - if v, ok := strings.CutPrefix(f, "-run="); ok { + if v, ok := strings.CutPrefix(f, name+"="); ok { return v } - if f == "-run" && i+1 < len(runFlags) { - return runFlags[i+1] + if f == name && i+1 < len(flags) { + return flags[i+1] } } return "" } -// StripRunFilter removes -run and its value from run flags. -func StripRunFilter(runFlags []string) []string { +// stripFlag removes a named flag and its value from flags, stopping at -args. +func stripFlag(flags []string, name string) []string { var out []string - for i := 0; i < len(runFlags); i++ { - f := runFlags[i] + for i := 0; i < len(flags); i++ { + f := flags[i] if f == "-args" { - out = append(out, runFlags[i:]...) + out = append(out, flags[i:]...) return out } - if strings.HasPrefix(f, "-run=") { + if strings.HasPrefix(f, name+"=") { continue } - if f == "-run" { + if f == name { i++ // skip value continue } @@ -243,42 +243,17 @@ func StripRunFilter(runFlags []string) []string { return out } +// ExtractRunFilter returns the value of -run from run flags, if present. +func ExtractRunFilter(runFlags []string) string { return extractFlag(runFlags, "-run") } + +// StripRunFilter removes -run and its value from run flags. +func StripRunFilter(runFlags []string) []string { return stripFlag(runFlags, "-run") } + // ExtractCoverProfile returns the value of -coverprofile from run flags, if present. -func ExtractCoverProfile(runFlags []string) string { - for i, f := range runFlags { - if f == "-args" { - return "" - } - if v, ok := strings.CutPrefix(f, "-coverprofile="); ok { - return v - } - if f == "-coverprofile" && i+1 < len(runFlags) { - return runFlags[i+1] - } - } - return "" -} +func ExtractCoverProfile(runFlags []string) string { return extractFlag(runFlags, "-coverprofile") } // StripCoverProfile removes -coverprofile and its value from run flags. -func StripCoverProfile(runFlags []string) []string { - var out []string - for i := 0; i < len(runFlags); i++ { - f := runFlags[i] - if f == "-args" { - out = append(out, runFlags[i:]...) - return out - } - if strings.HasPrefix(f, "-coverprofile=") { - continue - } - if f == "-coverprofile" { - i++ // skip value - continue - } - out = append(out, f) - } - return out -} +func StripCoverProfile(runFlags []string) []string { return stripFlag(runFlags, "-coverprofile") } // StripCoverBuildFlags removes coverage-related build flags (-cover, // -covermode, -coverpkg) that break packages.Load when passed as BuildFlags. @@ -366,20 +341,8 @@ func HasVerboseFlag(flags []string) bool { // ExtractParallelValue returns the integer value of -parallel from run flags. // Returns 0 if not present or not parseable. Stops scanning at -args. func ExtractParallelValue(runFlags []string) int { - for i, f := range runFlags { - if f == "-args" { - return 0 - } - if v, ok := strings.CutPrefix(f, "-parallel="); ok { - n, _ := strconv.Atoi(v) - return n - } - if f == "-parallel" && i+1 < len(runFlags) { - n, _ := strconv.Atoi(runFlags[i+1]) - return n - } - } - return 0 + v, _ := strconv.Atoi(extractFlag(runFlags, "-parallel")) + return v } // InjectParallel appends -parallel=n to runFlags if not already present diff --git a/internal/gotestrunner/export_test.go b/internal/gotestrunner/export_test.go index 8ac1f0bd..a43e8bf6 100644 --- a/internal/gotestrunner/export_test.go +++ b/internal/gotestrunner/export_test.go @@ -23,7 +23,7 @@ var ExportBuildBaseEnv = buildBaseEnv var ExportOverlayContentHash = overlayContentHash var ExportCacheRoot = cacheRoot var ExportFilterPackageLevelEvents = filterPackageLevelEvents -var ExportIsPackageSummaryLine = isPackageSummaryLine +var ExportIsPackageSummaryLine = protocol.IsPackageSummaryLine func ExportAutoDetectCI(cfg PipelineConfig) PipelineConfig { if !cfg.CI && os.Getenv(protocol.EnvCI) == "" && os.Getenv("CI") != "" { diff --git a/internal/gotestrunner/output_collector.go b/internal/gotestrunner/output_collector.go index a55716c2..ee37e573 100644 --- a/internal/gotestrunner/output_collector.go +++ b/internal/gotestrunner/output_collector.go @@ -8,9 +8,10 @@ import ( "maps" "os" "slices" - "strings" "sync" "time" + + "github.com/mvrahden/go-test/internal/protocol" ) // OutputCollector is a unified, mode-aware output pipeline that replaces @@ -284,20 +285,6 @@ func (c *OutputCollector) emitJSONPackageSummary(w io.Writer, pkg string, s *pkg }) } -// isPackageSummaryLine reports whether s (an "output" event's Output field) -// is one of the summary lines that go test / test2json synthesizes itself -// (e.g. "PASS", "FAIL\tpkg\t0.5s", "? \tpkg\t[no test files]"). These are -// re-synthesized by emitJSONPackageSummary, so raw copies from per-suite -// test2json instances must be dropped to avoid duplication. -// NOTE: keep in sync with internal/gotestspec/tree.go isPackageSummaryLine. -func isPackageSummaryLine(s string) bool { - s = strings.TrimRight(s, "\n\r") - return s == "PASS" || s == "FAIL" || - strings.HasPrefix(s, "ok \t") || - strings.HasPrefix(s, "FAIL\t") || - strings.HasPrefix(s, "? \t") -} - // filterPackageLevelEvents writes test-level JSON events (those with a // non-empty Test field) to w unchanged, strips package-level structural // events (start, pass, fail, etc.) and synthesized summary lines that would @@ -329,7 +316,7 @@ func filterPackageLevelEvents(w io.Writer, data []byte) { _, _ = w.Write([]byte{'\n'}) continue } - if ev.Action == "output" && !isPackageSummaryLine(ev.Output) { + if ev.Action == "output" && !protocol.IsPackageSummaryLine(ev.Output) { _, _ = w.Write(line) _, _ = w.Write([]byte{'\n'}) } diff --git a/internal/gotestspec/tree.go b/internal/gotestspec/tree.go index 3b3e989b..f1c3509c 100644 --- a/internal/gotestspec/tree.go +++ b/internal/gotestspec/tree.go @@ -4,6 +4,8 @@ import ( "sort" "strings" "time" + + "github.com/mvrahden/go-test/internal/protocol" ) type Status int @@ -83,7 +85,7 @@ func BuildTree(events []TestEvent) []*Package { pkg.Status = statusFrom(ev.Action) pkg.Duration = elapsed(ev.Elapsed) case ActionOutput: - if !isPackageSummaryLine(ev.Output) { + if !protocol.IsPackageSummaryLine(ev.Output) { pkg.Output = append(pkg.Output, ev.Output) } } @@ -267,44 +269,44 @@ func classify(n *Node, topLevel bool) { if topLevel { raw := strings.TrimPrefix(name, "Test") - if strings.HasPrefix(raw, "F_") { + if strings.HasPrefix(raw, protocol.PrefixFocused) { n.Focused = true - raw = raw[2:] - } else if strings.HasPrefix(raw, "X_") { + raw = strings.TrimPrefix(raw, protocol.PrefixFocused) + } else if strings.HasPrefix(raw, protocol.PrefixExcluded) { n.Excluded = true - raw = raw[2:] + raw = strings.TrimPrefix(raw, protocol.PrefixExcluded) } switch { - case strings.HasPrefix(raw, "_") && strings.HasSuffix(raw, "Fixture"): + case strings.HasPrefix(raw, "_") && strings.HasSuffix(raw, protocol.SuffixFixture): n.Kind = KindFixture - n.Display = strings.TrimSuffix(strings.TrimPrefix(raw, "_"), "Fixture") - case strings.HasSuffix(raw, "TestSuite"): + n.Display = strings.TrimSuffix(strings.TrimPrefix(raw, "_"), protocol.SuffixFixture) + case strings.HasSuffix(raw, protocol.SuffixTestSuite): n.Kind = KindSuite - n.Display = strings.TrimSuffix(raw, "TestSuite") + n.Display = strings.TrimSuffix(raw, protocol.SuffixTestSuite) default: n.Kind = KindTest n.Display = strings.TrimPrefix(raw, "_") } } else { - if strings.HasPrefix(name, "F_") { + if strings.HasPrefix(name, protocol.PrefixFocused) { n.Focused = true - name = name[2:] - } else if strings.HasPrefix(name, "X_") { + name = strings.TrimPrefix(name, protocol.PrefixFocused) + } else if strings.HasPrefix(name, protocol.PrefixExcluded) { n.Excluded = true - name = name[2:] + name = strings.TrimPrefix(name, protocol.PrefixExcluded) } switch { case strings.HasPrefix(name, "Test"): n.Kind = KindMethod n.Display = strings.TrimPrefix(name, "Test") - case strings.HasSuffix(name, "Fixture") && !strings.HasSuffix(name, "TestSuite"): + case strings.HasSuffix(name, protocol.SuffixFixture) && !strings.HasSuffix(name, protocol.SuffixTestSuite): n.Kind = KindFixture - n.Display = strings.TrimSuffix(name, "Fixture") - case strings.HasSuffix(name, "TestSuite"): + n.Display = strings.TrimSuffix(name, protocol.SuffixFixture) + case strings.HasSuffix(name, protocol.SuffixTestSuite): n.Kind = KindSuite - n.Display = strings.TrimSuffix(name, "TestSuite") + n.Display = strings.TrimSuffix(name, protocol.SuffixTestSuite) default: n.Kind = KindBlock n.Display = strings.ReplaceAll(name, "_", " ") @@ -357,16 +359,3 @@ func statusFrom(a Action) Status { func elapsed(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } - -// isPackageSummaryLine reports whether s is a `go test` package-level summary -// line (e.g. "PASS", "FAIL", "ok \tpkg\t0.01s", "FAIL\tpkg\t0.01s", -// "? \tpkg\t[no test files]") rather than diagnostic output that should be -// surfaced to the user. -// NOTE: keep in sync with internal/gotestrunner/output_collector.go isPackageSummaryLine. -func isPackageSummaryLine(s string) bool { - s = strings.TrimRight(s, "\n\r") - return s == "PASS" || s == "FAIL" || - strings.HasPrefix(s, "ok \t") || - strings.HasPrefix(s, "FAIL\t") || - strings.HasPrefix(s, "? \t") -} diff --git a/internal/lint/lint.go b/internal/lint/lint.go index bc92fa4c..a3c5424b 100644 --- a/internal/lint/lint.go +++ b/internal/lint/lint.go @@ -11,6 +11,8 @@ import ( "unicode" "github.com/mvrahden/go-test/internal/about" + "github.com/mvrahden/go-test/internal/gotestast" + "github.com/mvrahden/go-test/internal/protocol" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" @@ -211,8 +213,8 @@ func discoverSuites(insp *inspector.Inspector) map[string]*suiteInfo { continue } name := ts.Name.Name - stripped := strings.TrimPrefix(strings.TrimPrefix(name, "F_"), "X_") - if strings.HasSuffix(stripped, "TestSuite") { + stripped := strings.TrimPrefix(strings.TrimPrefix(name, protocol.PrefixFocused), protocol.PrefixExcluded) + if strings.HasSuffix(stripped, protocol.SuffixTestSuite) { suites[name] = &suiteInfo{ name: name, pos: ts.Pos(), @@ -230,8 +232,8 @@ func discoverSuites(insp *inspector.Inspector) map[string]*suiteInfo { // behind a green CI run. func checkFocusPrefixes(pass *analysis.Pass, suites map[string]*suiteInfo) { for name, s := range suites { - if strings.HasPrefix(name, "F_") { - stripped := strings.TrimPrefix(name, "F_") + if strings.HasPrefix(name, protocol.PrefixFocused) { + stripped := strings.TrimPrefix(name, protocol.PrefixFocused) edits := []analysis.TextEdit{{ Pos: s.pos, End: s.pos + 2, @@ -287,12 +289,12 @@ func checkMethods(pass *analysis.Pass, insp *inspector.Inspector, suites map[str "suite method %s.%s should use a pointer receiver", recvName, methodName) } - stripped := strings.TrimPrefix(strings.TrimPrefix(methodName, "F_"), "X_") + stripped := strings.TrimPrefix(strings.TrimPrefix(methodName, protocol.PrefixFocused), protocol.PrefixExcluded) if strings.HasPrefix(stripped, "Test") { - if strings.HasPrefix(methodName, "F_") { + if strings.HasPrefix(methodName, protocol.PrefixFocused) { reportWithFix(pass, Focus, fd.Pos(), []analysis.SuggestedFix{{ - Message: fmt.Sprintf("rename %s to %s", methodName, strings.TrimPrefix(methodName, "F_")), + Message: fmt.Sprintf("rename %s to %s", methodName, strings.TrimPrefix(methodName, protocol.PrefixFocused)), TextEdits: []analysis.TextEdit{{ Pos: fd.Name.Pos(), End: fd.Name.Pos() + 2, @@ -308,7 +310,7 @@ func checkMethods(pass *analysis.Pass, insp *inspector.Inspector, suites map[str } if isLifecycleHook(stripped) { - if strings.HasPrefix(methodName, "X_") { + if strings.HasPrefix(methodName, protocol.PrefixExcluded) { report(pass, XLifecycle, fd.Pos(), "X_ prefix on lifecycle hook %s.%s has no effect — remove the prefix or the method", recvName, methodName) } return @@ -526,7 +528,7 @@ func collectInterproceduralEscape(call *ast.CallExpr, tVars, gotestTVars map[str } } -const gotestImportPath = "github.com/mvrahden/go-test/pkg/gotest" +var gotestImportPath = about.Repo + "/pkg/gotest" func isGotestPkgRef(pass *analysis.Pass, expr ast.Expr) bool { id, ok := expr.(*ast.Ident) @@ -934,23 +936,7 @@ func receiverTypeName(recv *ast.FieldList) string { if recv == nil || len(recv.List) == 0 { return "" } - t := recv.List[0].Type - if star, ok := t.(*ast.StarExpr); ok { - t = star.X - } - switch x := t.(type) { - case *ast.Ident: - return x.Name - case *ast.IndexExpr: - if ident, ok := x.X.(*ast.Ident); ok { - return ident.Name - } - case *ast.IndexListExpr: - if ident, ok := x.X.(*ast.Ident); ok { - return ident.Name - } - } - return "" + return gotestast.ReceiverTypeName(recv.List[0].Type) } func isPointerReceiver(recv *ast.FieldList) bool { diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 564b85b7..36101c2b 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -10,9 +10,12 @@ import ( "os" "path/filepath" "strings" + + "github.com/mvrahden/go-test/internal/about" + "github.com/mvrahden/go-test/internal/gotestast" ) -const gotestImport = "github.com/mvrahden/go-test/pkg/gotest" +var gotestImport = about.Repo + "/pkg/gotest" // MigrationPlan describes all suites found in a single file. type MigrationPlan struct { @@ -171,14 +174,7 @@ func extractReceiverInfo(field *ast.Field) (typeName, varName string) { if len(field.Names) > 0 { varName = field.Names[0].Name } - switch t := field.Type.(type) { - case *ast.StarExpr: - if ident, ok := t.X.(*ast.Ident); ok { - typeName = ident.Name - } - case *ast.Ident: - typeName = t.Name - } + typeName = gotestast.ReceiverTypeName(field.Type) return } diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index eaa04825..df065d41 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -1,5 +1,7 @@ package protocol +import "strings" + const ( EnvSharedStateFile = "GOTEST_SHARED_STATE_FILE" EnvTeardownBudgetFile = "GOTEST_TEARDOWN_BUDGET_FILE" @@ -8,6 +10,25 @@ const ( EnvCacheDir = "GOTEST_CACHE_DIR" ) +const ( + SuffixFixture = "Fixture" + SuffixSharedFixture = "SharedFixture" + SuffixTestSuite = "TestSuite" + PrefixFocused = "F_" + PrefixExcluded = "X_" +) + func BudgetFilePath(binaryPath string) string { return binaryPath + ".budget" } + +// IsPackageSummaryLine reports whether s is a go test package-level summary +// line (e.g. "PASS", "FAIL", "ok \tpkg\t0.01s") rather than diagnostic +// output that should be surfaced to the user. +func IsPackageSummaryLine(s string) bool { + s = strings.TrimRight(s, "\n\r") + return s == "PASS" || s == "FAIL" || + strings.HasPrefix(s, "ok \t") || + strings.HasPrefix(s, "FAIL\t") || + strings.HasPrefix(s, "? \t") +} diff --git a/internal/refactor/togglefocus.go b/internal/refactor/togglefocus.go index cb5fa6a8..8341f3d4 100644 --- a/internal/refactor/togglefocus.go +++ b/internal/refactor/togglefocus.go @@ -8,6 +8,9 @@ import ( "os" "sort" "strings" + + "github.com/mvrahden/go-test/internal/gotestast" + "github.com/mvrahden/go-test/internal/protocol" ) type replacement struct { @@ -118,22 +121,14 @@ func toggleMethod(filePath string, src []byte, fset *token.FileSet, file *ast.Fi } func togglePrefix(name string) string { - if strings.HasPrefix(name, "F_") { - return name[2:] + if strings.HasPrefix(name, protocol.PrefixFocused) { + return strings.TrimPrefix(name, protocol.PrefixFocused) } - return "F_" + name + return protocol.PrefixFocused + name } func receiverTypeName(expr ast.Expr) string { - switch t := expr.(type) { - case *ast.StarExpr: - if ident, ok := t.X.(*ast.Ident); ok { - return ident.Name - } - case *ast.Ident: - return t.Name - } - return "" + return gotestast.ReceiverTypeName(expr) } func receiverNameOffset(fset *token.FileSet, expr ast.Expr) int { diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index dfbd7f8e..367d4909 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -12,6 +12,7 @@ import ( "text/template" "unicode" + "github.com/mvrahden/go-test/internal/gotestgen" "golang.org/x/tools/go/packages" ) @@ -129,7 +130,7 @@ func IntrospectType(pkgPattern, typeName string) (*TypeInfo, error) { info := &TypeInfo{ Name: typeName, PkgName: pkg.Name, - PkgDir: determinePkgDir(pkg), + PkgDir: gotestgen.DeterminePkgDir(pkg), } // Check if interface @@ -226,7 +227,7 @@ func IntrospectFile(pkgPattern, filename string) (*FileInfo, error) { return &FileInfo{ SuiteName: suiteName, PkgName: pkg.Name, - PkgDir: determinePkgDir(pkg), + PkgDir: gotestgen.DeterminePkgDir(pkg), Funcs: funcs, }, nil } @@ -243,16 +244,6 @@ func toPascalCase(s string) string { return result.String() } -func determinePkgDir(pkg *packages.Package) string { - if len(pkg.GoFiles) > 0 { - return filepath.Dir(pkg.GoFiles[0]) - } - if len(pkg.CompiledGoFiles) > 0 { - return filepath.Dir(pkg.CompiledGoFiles[0]) - } - return "" -} - func extractStructMethods(named *types.Named) []MethodInfo { // Get pointer receiver methods (includes value receiver methods) mset := types.NewMethodSet(types.NewPointer(named)) diff --git a/pkg/gotestruntime/fixture.go b/pkg/gotestruntime/fixture.go index ca0321fe..6706dc88 100644 --- a/pkg/gotestruntime/fixture.go +++ b/pkg/gotestruntime/fixture.go @@ -20,25 +20,14 @@ type SharedStateNode struct { // FixtureNode describes one fixture in the dependency graph. // Generated code populates this as a struct literal. type FixtureNode struct { - Name string - Config gotest.FixtureConfig - Init func() - BeforeAll func(ctx context.Context) error - AfterAll func(ctx context.Context) error - SharedFixtures []SharedFixtureBinding // deprecated: use SharedStateNode as DAG node - SharedState *SharedStateNode // non-nil for shared fixture nodes - Children []*FixtureNode // deprecated: use DependsOn with MainConfig.Fixtures - DependsOn []string -} - -// SharedFixtureBinding describes how to deserialize and hydrate a shared -// fixture, then assign it to the parent fixture struct. -type SharedFixtureBinding struct { - StateKey string - Target any - Hydrate func(ctx context.Context) error - Dehydrate func(ctx context.Context) error - Assign func() + Name string + Config gotest.FixtureConfig + Init func() + BeforeAll func(ctx context.Context) error + AfterAll func(ctx context.Context) error + SharedState *SharedStateNode // non-nil for shared fixture nodes + Children []*FixtureNode // deprecated: use DependsOn with MainConfig.Fixtures + DependsOn []string } type MainConfig struct { diff --git a/pkg/gotestruntime/runtime.go b/pkg/gotestruntime/runtime.go index 1870b858..6a4cf065 100644 --- a/pkg/gotestruntime/runtime.go +++ b/pkg/gotestruntime/runtime.go @@ -15,7 +15,7 @@ func run(runTests func() int, cfg MainConfig) int { tracker := &nodeTracker{succeeded: make(map[*FixtureNode]bool)} var sharedState map[string]json.RawMessage - if anyNodeHasSharedFixtures(cfg.Roots, cfg.Fixtures) { + if anyNodeHasSharedState(cfg.Fixtures) { if os.Getenv(protocol.EnvSharedStateFile) != "" { var err error sharedState, err = loadSharedState() @@ -23,9 +23,6 @@ func run(runTests func() int, cfg MainConfig) int { fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) return 2 } - } else if anyNodeHasLegacySharedFixtures(cfg.Roots, cfg.Fixtures) { - fmt.Fprintf(os.Stderr, "FAIL: %s not set — run via gotest CLI\n", protocol.EnvSharedStateFile) - return 2 } } @@ -48,7 +45,7 @@ func run(runTests func() int, cfg MainConfig) int { return code } - if err := setupRoots(ctx, cfg.Roots, sharedState, tracker); err != nil { + if err := setupRoots(ctx, cfg.Roots, tracker); err != nil { _ = teardownRoots(cfg.Roots, tracker) return 2 } @@ -64,7 +61,7 @@ func run(runTests func() int, cfg MainConfig) int { return code } -func setupRoots(ctx context.Context, roots []*FixtureNode, sharedState map[string]json.RawMessage, tracker *nodeTracker) error { +func setupRoots(ctx context.Context, roots []*FixtureNode, tracker *nodeTracker) error { errs := make([]error, len(roots)) var wg sync.WaitGroup @@ -81,7 +78,7 @@ func setupRoots(ctx context.Context, roots []*FixtureNode, sharedState map[strin cancel() } }() - if err := setupNode(childCtx, root, sharedState, tracker); err != nil { + if err := setupNode(childCtx, root, tracker); err != nil { errs[i] = err cancel() } @@ -97,45 +94,17 @@ func setupRoots(ctx context.Context, roots []*FixtureNode, sharedState map[strin return nil } -func setupNode(ctx context.Context, node *FixtureNode, sharedState map[string]json.RawMessage, tracker *nodeTracker) error { - // 1-2. Unmarshal and hydrate shared fixtures - if len(node.SharedFixtures) > 0 { - for _, sf := range node.SharedFixtures { - raw, ok := sharedState[sf.StateKey] - if !ok { - return fmt.Errorf("shared fixture state key %q not found in state file", sf.StateKey) - } - if err := json.Unmarshal(raw, sf.Target); err != nil { - return fmt.Errorf("unmarshal shared fixture %q: %w", sf.StateKey, err) - } - if sf.Hydrate != nil { - if err := sf.Hydrate(ctx); err != nil { - return fmt.Errorf("hydrate shared fixture %q: %w", sf.StateKey, err) - } - } - } - } - - // 3. Init +func setupNode(ctx context.Context, node *FixtureNode, tracker *nodeTracker) error { if node.Init != nil { node.Init() } - // 4. Assign shared fixtures - for _, sf := range node.SharedFixtures { - if sf.Assign != nil { - sf.Assign() - } - } - - // 5. BeforeAll with retry/timeout if err := runBeforeAllWithRetry(ctx, node); err != nil { return err } tracker.markSucceeded(node) - // 6. Setup children concurrently if len(node.Children) > 0 { errs := make([]error, len(node.Children)) var wg sync.WaitGroup @@ -153,7 +122,7 @@ func setupNode(ctx context.Context, node *FixtureNode, sharedState map[string]js cancel() } }() - if err := setupNode(childCtx, child, sharedState, tracker); err != nil { + if err := setupNode(childCtx, child, tracker); err != nil { errs[i] = err cancel() } @@ -274,33 +243,10 @@ func setupNodeDAG(ctx context.Context, node *FixtureNode, sharedState map[string return nil } - if len(node.SharedFixtures) > 0 { - for _, sf := range node.SharedFixtures { - raw, ok := sharedState[sf.StateKey] - if !ok { - return fmt.Errorf("shared fixture state key %q not found in state file", sf.StateKey) - } - if err := json.Unmarshal(raw, sf.Target); err != nil { - return fmt.Errorf("unmarshal shared fixture %q: %w", sf.StateKey, err) - } - if sf.Hydrate != nil { - if err := sf.Hydrate(ctx); err != nil { - return fmt.Errorf("hydrate shared fixture %q: %w", sf.StateKey, err) - } - } - } - } - if node.Init != nil { node.Init() } - for _, sf := range node.SharedFixtures { - if sf.Assign != nil { - sf.Assign() - } - } - if err := runBeforeAllWithRetry(ctx, node); err != nil { return err } @@ -363,17 +309,6 @@ func teardownDAG(fixtures []*FixtureNode, tracker *nodeTracker) bool { mu.Unlock() } } - - for _, sf := range node.SharedFixtures { - if sf.Dehydrate != nil { - if err := sf.Dehydrate(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "%s: dehydrate failed: %v\n", node.Name, err) - mu.Lock() - failed[node.Name] = true - mu.Unlock() - } - } - } } }(f) } @@ -494,15 +429,6 @@ func teardownNode(node *FixtureNode, tracker *nodeTracker) bool { anyFailed = true } } - - for _, sf := range node.SharedFixtures { - if sf.Dehydrate != nil { - if err := sf.Dehydrate(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "%s: dehydrate failed: %v\n", node.Name, err) - anyFailed = true - } - } - } } return anyFailed @@ -611,40 +537,9 @@ func loadSharedState() (map[string]json.RawMessage, error) { return state, nil } -func anyNodeHasSharedFixtures(roots, fixtures []*FixtureNode) bool { - for _, f := range fixtures { - if len(f.SharedFixtures) > 0 || f.SharedState != nil { - return true - } - } - for _, root := range roots { - if hasSharedFixtures(root) { - return true - } - } - return false -} - -func anyNodeHasLegacySharedFixtures(roots, fixtures []*FixtureNode) bool { +func anyNodeHasSharedState(fixtures []*FixtureNode) bool { for _, f := range fixtures { - if len(f.SharedFixtures) > 0 { - return true - } - } - for _, root := range roots { - if hasSharedFixtures(root) { - return true - } - } - return false -} - -func hasSharedFixtures(node *FixtureNode) bool { - if len(node.SharedFixtures) > 0 { - return true - } - for _, child := range node.Children { - if hasSharedFixtures(child) { + if f.SharedState != nil { return true } } @@ -672,15 +567,13 @@ func SetupFixtureDAG(ctx context.Context, cfg MainConfig) (*FixtureDAG, error) { tracker := &nodeTracker{succeeded: make(map[*FixtureNode]bool)} var sharedState map[string]json.RawMessage - if anyNodeHasSharedFixtures(cfg.Roots, cfg.Fixtures) { + if anyNodeHasSharedState(cfg.Fixtures) { if os.Getenv(protocol.EnvSharedStateFile) != "" { var err error sharedState, err = loadSharedState() if err != nil { return nil, fmt.Errorf("load shared state: %w", err) } - } else if anyNodeHasLegacySharedFixtures(cfg.Roots, cfg.Fixtures) { - return nil, fmt.Errorf("%s not set — run via gotest CLI", protocol.EnvSharedStateFile) } } @@ -690,7 +583,7 @@ func SetupFixtureDAG(ctx context.Context, cfg MainConfig) (*FixtureDAG, error) { return nil, err } } else if len(cfg.Roots) > 0 { - if err := setupRoots(ctx, cfg.Roots, sharedState, tracker); err != nil { + if err := setupRoots(ctx, cfg.Roots, tracker); err != nil { _ = teardownRoots(cfg.Roots, tracker) return nil, err } diff --git a/pkg/gotestruntime/runtime_test.go b/pkg/gotestruntime/runtime_test.go index 989feaac..3de8cec2 100644 --- a/pkg/gotestruntime/runtime_test.go +++ b/pkg/gotestruntime/runtime_test.go @@ -2,7 +2,6 @@ package gotestruntime //nolint:stdlib-test import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -735,140 +734,6 @@ func TestMultipleRoots_ConcurrentTeardown(t *testing.T) { gotest.Equal(t, 0, exitCode) } -func TestSharedFixture_LoadAndHydrate(t *testing.T) { - rec := &recorder{} - - type SharedDB struct { - Host string `json:"host"` - Port int `json:"port"` - } - - stateData := map[string]json.RawMessage{ - "example.com/fixtures.SharedDB": json.RawMessage(`{"host":"localhost","port":5432}`), - } - stateBytes, _ := json.Marshal(stateData) - stateFile := filepath.Join(t.TempDir(), "state.json") - _ = os.WriteFile(stateFile, stateBytes, 0600) - t.Setenv(protocol.EnvSharedStateFile, stateFile) - - var target SharedDB - var assignedHost string - - node := &FixtureNode{ - Name: "Root", - Config: gotest.DefaultFixtureConfig(), - Init: func() { rec.record("root.init") }, - BeforeAll: func(ctx context.Context) error { - rec.record("root.beforeAll") - return nil - }, - AfterAll: func(ctx context.Context) error { - rec.record("root.afterAll") - return nil - }, - SharedFixtures: []SharedFixtureBinding{ - { - StateKey: "example.com/fixtures.SharedDB", - Target: &target, - Hydrate: func(ctx context.Context) error { - rec.record("sf.hydrate") - return nil - }, - Dehydrate: func(ctx context.Context) error { - rec.record("sf.dehydrate") - return nil - }, - Assign: func() { - rec.record("sf.assign") - assignedHost = target.Host - }, - }, - }, - } - - exitCode := run(func() int { - rec.record("m.run") - return 0 - }, MainConfig{Roots: []*FixtureNode{node}}) - - gotest.Equal(t, 0, exitCode) - gotest.Equal(t, "localhost", target.Host) - gotest.Equal(t, 5432, target.Port) - gotest.Equal(t, "localhost", assignedHost) - - events := rec.names() - // Order: hydrate → init → assign → beforeAll → m.run → afterAll → dehydrate - hydrateIdx := indexOf(events, "sf.hydrate") - initIdx := indexOf(events, "root.init") - assignIdx := indexOf(events, "sf.assign") - beforeAllIdx := indexOf(events, "root.beforeAll") - mRunIdx := indexOf(events, "m.run") - afterAllIdx := indexOf(events, "root.afterAll") - dehydrateIdx := indexOf(events, "sf.dehydrate") - - gotest.Less(t, hydrateIdx, initIdx) - gotest.Less(t, initIdx, assignIdx) - gotest.Less(t, assignIdx, beforeAllIdx) - gotest.Less(t, beforeAllIdx, mRunIdx) - gotest.Less(t, mRunIdx, afterAllIdx) - gotest.Less(t, afterAllIdx, dehydrateIdx) -} - -func TestSharedFixture_MissingEnvVar(t *testing.T) { - t.Setenv(protocol.EnvSharedStateFile, "") - - node := &FixtureNode{ - Name: "Root", - Config: gotest.DefaultFixtureConfig(), - Init: func() {}, - BeforeAll: func(ctx context.Context) error { return nil }, - SharedFixtures: []SharedFixtureBinding{ - { - StateKey: "example.com/fixtures.SharedDB", - Target: &struct{}{}, - }, - }, - } - - exitCode := run(func() int { return 0 }, MainConfig{Roots: []*FixtureNode{node}}) - gotest.Equal(t, 2, exitCode) -} - -func TestSharedFixture_NilHydrateAndDehydrate(t *testing.T) { - type SharedDB struct { - Host string `json:"host"` - } - - stateData := map[string]json.RawMessage{ - "key": json.RawMessage(`{"host":"db"}`), - } - stateBytes, _ := json.Marshal(stateData) - stateFile := filepath.Join(t.TempDir(), "state.json") - _ = os.WriteFile(stateFile, stateBytes, 0600) - t.Setenv(protocol.EnvSharedStateFile, stateFile) - - var target SharedDB - node := &FixtureNode{ - Name: "Root", - Config: gotest.DefaultFixtureConfig(), - Init: func() {}, - BeforeAll: func(ctx context.Context) error { return nil }, - SharedFixtures: []SharedFixtureBinding{ - { - StateKey: "key", - Target: &target, - Hydrate: nil, - Dehydrate: nil, - Assign: func() {}, - }, - }, - } - - exitCode := run(func() int { return 0 }, MainConfig{Roots: []*FixtureNode{node}}) - gotest.Equal(t, 0, exitCode) - gotest.Equal(t, "db", target.Host) -} - func TestBudgetFile_WrittenCorrectly(t *testing.T) { budgetFile := filepath.Join(t.TempDir(), "budget") t.Setenv(protocol.EnvTeardownBudgetFile, budgetFile) @@ -1283,84 +1148,6 @@ func TestDAG_DependencyFailure_PartialTeardown(t *testing.T) { gotest.NotContains(t, events, "b.afterAll") } -func TestDAG_SharedFixtureWithDAGPath(t *testing.T) { - rec := &recorder{} - - type SharedDB struct { - Host string `json:"host"` - Port int `json:"port"` - } - - stateData := map[string]json.RawMessage{ - "example.com/fixtures.SharedDB": json.RawMessage(`{"host":"localhost","port":5432}`), - } - stateBytes, _ := json.Marshal(stateData) - stateFile := filepath.Join(t.TempDir(), "state.json") - _ = os.WriteFile(stateFile, stateBytes, 0600) - t.Setenv(protocol.EnvSharedStateFile, stateFile) - - var target SharedDB - var assignedHost string - - node := &FixtureNode{ - Name: "Root", - Config: gotest.DefaultFixtureConfig(), - Init: func() { rec.record("root.init") }, - BeforeAll: func(ctx context.Context) error { - rec.record("root.beforeAll") - return nil - }, - AfterAll: func(ctx context.Context) error { - rec.record("root.afterAll") - return nil - }, - SharedFixtures: []SharedFixtureBinding{ - { - StateKey: "example.com/fixtures.SharedDB", - Target: &target, - Hydrate: func(ctx context.Context) error { - rec.record("sf.hydrate") - return nil - }, - Dehydrate: func(ctx context.Context) error { - rec.record("sf.dehydrate") - return nil - }, - Assign: func() { - rec.record("sf.assign") - assignedHost = target.Host - }, - }, - }, - } - - exitCode := run(func() int { - rec.record("m.run") - return 0 - }, MainConfig{Fixtures: []*FixtureNode{node}}) - - gotest.Equal(t, 0, exitCode) - gotest.Equal(t, "localhost", target.Host) - gotest.Equal(t, 5432, target.Port) - gotest.Equal(t, "localhost", assignedHost) - - events := rec.names() - hydrateIdx := indexOf(events, "sf.hydrate") - initIdx := indexOf(events, "root.init") - assignIdx := indexOf(events, "sf.assign") - beforeAllIdx := indexOf(events, "root.beforeAll") - mRunIdx := indexOf(events, "m.run") - afterAllIdx := indexOf(events, "root.afterAll") - dehydrateIdx := indexOf(events, "sf.dehydrate") - - gotest.Less(t, hydrateIdx, initIdx) - gotest.Less(t, initIdx, assignIdx) - gotest.Less(t, assignIdx, beforeAllIdx) - gotest.Less(t, beforeAllIdx, mRunIdx) - gotest.Less(t, mRunIdx, afterAllIdx) - gotest.Less(t, afterAllIdx, dehydrateIdx) -} - func TestDAG_ComputeMaxPath(t *testing.T) { fixtures := []*FixtureNode{ {Name: "A", Config: gotest.FixtureConfig{Timeout: 1 * time.Minute}}, diff --git a/vscode-gotest/src/batchRunner.ts b/vscode-gotest/src/batchRunner.ts index 842b1577..858b9a9d 100644 --- a/vscode-gotest/src/batchRunner.ts +++ b/vscode-gotest/src/batchRunner.ts @@ -6,6 +6,7 @@ import type { GoTestController } from "./testController.js"; import type { CoverageStore } from "./coverageStore.js"; import { type TestEvent } from "./outputParser.js"; import { buildCliCommand, clearBinaryCache, formatCliCommand } from "./cli.js"; +import { readModulePath } from "./gomod.js"; import { applyEvent, skipUnresolved, @@ -268,13 +269,3 @@ export async function executeBatch(config: BatchConfig): Promise { } } } - -async function readModulePath(dir: string): Promise { - try { - const content = await readFile(path.join(dir, "go.mod"), "utf-8"); - const match = /^\s*module\s+(\S+)/m.exec(content); - return match?.[1]; - } catch { - return undefined; - } -} diff --git a/vscode-gotest/src/cli.ts b/vscode-gotest/src/cli.ts index 90b80dda..6b677948 100644 --- a/vscode-gotest/src/cli.ts +++ b/vscode-gotest/src/cli.ts @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolveGoBinary, fileExists, clearGoBinaryCache } from "./goBinary.js"; +import { readModulePath } from "./gomod.js"; export { resolveGoBinary } from "./goBinary.js"; @@ -79,7 +80,7 @@ export async function buildCliCommand( // 2. Workspace IS the gotest module (development / go.work overlap) if (effectiveDir) { - const declaredModule = await readModuleDeclaration(effectiveDir); + const declaredModule = await readModulePath(effectiveDir); if ( declaredModule && (modulePath === declaredModule || @@ -135,19 +136,6 @@ export async function buildCliCommand( return { bin: goBin, args: ["run", qualified, ...subcommandArgs] }; } -async function readModuleDeclaration( - workspaceDir: string, -): Promise { - try { - const goModPath = path.join(workspaceDir, "go.mod"); - const content = await readFile(goModPath, "utf-8"); - const match = /^\s*module\s+(\S+)/m.exec(content); - return match?.[1]; - } catch { - return undefined; - } -} - function resolveCliPath(cliPath: string, workspaceDir?: string): string { if (path.isAbsolute(cliPath)) { return cliPath; diff --git a/vscode-gotest/src/coverageStore.ts b/vscode-gotest/src/coverageStore.ts index 8e5ec39d..b54abeab 100644 --- a/vscode-gotest/src/coverageStore.ts +++ b/vscode-gotest/src/coverageStore.ts @@ -50,10 +50,6 @@ export class CoverageStore implements vscode.Disposable { return this.cachedDetails.get(absPath) ?? []; } - has(importPath: string): boolean { - return this.packages.has(importPath); - } - update( importPath: string, coverprofile: string, diff --git a/vscode-gotest/src/discovery.ts b/vscode-gotest/src/discovery.ts index 6fbc9a0f..c4fb71a9 100644 --- a/vscode-gotest/src/discovery.ts +++ b/vscode-gotest/src/discovery.ts @@ -118,16 +118,6 @@ export class DiscoveryCache implements vscode.Disposable { this._onDidUpdate.fire(); } - clear(): void { - this.cache.clear(); - this.dirIndex.clear(); - this.workspaceDirs.clear(); - this._warnings = []; - this.moduleDirs.clear(); - this.pkgModules.clear(); - this._onDidUpdate.fire(); - } - dispose(): void { this._onDidUpdate.dispose(); } diff --git a/vscode-gotest/src/gomod.ts b/vscode-gotest/src/gomod.ts new file mode 100644 index 00000000..2283cbac --- /dev/null +++ b/vscode-gotest/src/gomod.ts @@ -0,0 +1,12 @@ +import * as path from "node:path"; +import { readFile } from "node:fs/promises"; + +export async function readModulePath(dir: string): Promise { + try { + const content = await readFile(path.join(dir, "go.mod"), "utf-8"); + const match = /^\s*module\s+(\S+)/m.exec(content); + return match?.[1]; + } catch { + return undefined; + } +} diff --git a/vscode-gotest/src/outputParser.test.ts b/vscode-gotest/src/outputParser.test.ts index 68810541..b1dfb8ec 100644 --- a/vscode-gotest/src/outputParser.test.ts +++ b/vscode-gotest/src/outputParser.test.ts @@ -3,6 +3,7 @@ import { parseTestEvents, extractTestMessages, extractDiagnosticLocation, + isPackageSummaryLine, parseExpectedActual, } from "./outputParser.js"; @@ -221,6 +222,23 @@ describe("extractDiagnosticLocation", () => { }); }); +describe("isPackageSummaryLine", () => { + it.each([ + ["PASS\n", true], + ["FAIL\n", true], + ["ok \tpkg\t0.5s\n", true], + ["FAIL\tpkg\t1.2s\n", true], + ["? \tpkg\t[no test files]\n", true], + ["WARNING: DATA RACE\n", false], + ["==================\n", false], + ["Found 1 data race(s)\n", false], + ["panic: boom\n", false], + ["goroutine 1 [running]:\n", false], + ])("%s → %s", (input, expected) => { + expect(isPackageSummaryLine(input)).toBe(expected); + }); +}); + describe("parseExpectedActual", () => { it("extracts expected and actual values", () => { const message = diff --git a/vscode-gotest/src/outputParser.ts b/vscode-gotest/src/outputParser.ts index ea30e3f6..88eb92a3 100644 --- a/vscode-gotest/src/outputParser.ts +++ b/vscode-gotest/src/outputParser.ts @@ -41,21 +41,6 @@ export function parseTestEvents(jsonLines: string): TestEvent[] { return events; } -export function groupEventsByPackage( - events: TestEvent[], -): Map { - const groups = new Map(); - for (const event of events) { - let list = groups.get(event.Package); - if (!list) { - list = []; - groups.set(event.Package, list); - } - list.push(event); - } - return groups; -} - /** * Extract file:line:message patterns from test output. * Pattern: /^\s*(.+?):(\d+):\s*(.+)$/ @@ -124,6 +109,17 @@ function isStdlibPath(file: string): boolean { return seg !== "" && !seg.includes("."); } +export function isPackageSummaryLine(s: string): boolean { + const trimmed = s.replace(/[\n\r]+$/, ""); + return ( + trimmed === "PASS" || + trimmed === "FAIL" || + trimmed.startsWith("ok \t") || + trimmed.startsWith("FAIL\t") || + trimmed.startsWith("? \t") + ); +} + export function parseExpectedActual( message: string, ): { expected: string; actual: string } | undefined { diff --git a/vscode-gotest/src/runnerUtils.ts b/vscode-gotest/src/runnerUtils.ts index 1dba1e26..f8c7aeb7 100644 --- a/vscode-gotest/src/runnerUtils.ts +++ b/vscode-gotest/src/runnerUtils.ts @@ -7,6 +7,7 @@ import type { DiscoveryCache } from "./discovery.js"; import { extractDiagnosticLocation, extractTestMessages, + isPackageSummaryLine, parseExpectedActual, type TestEvent, } from "./outputParser.js"; @@ -226,8 +227,11 @@ export function applyEvent( ): AppliedResult | undefined { if (event.Action === "output") { const key = event.Test ?? ""; - const existing = outputMap.get(key) ?? ""; - outputMap.set(key, existing + (event.Output ?? "")); + const output = event.Output ?? ""; + if (!(key === "" && isPackageSummaryLine(output))) { + const existing = outputMap.get(key) ?? ""; + outputMap.set(key, existing + output); + } } if (event.Action === "output" && event.Output) { diff --git a/vscode-gotest/src/specView.ts b/vscode-gotest/src/specView.ts index cb569e50..3429872e 100644 --- a/vscode-gotest/src/specView.ts +++ b/vscode-gotest/src/specView.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import * as path from "node:path"; -import { readFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import { buildCliCommand } from "./cli.js"; +import { readModulePath } from "./gomod.js"; import type { DiscoveryCache } from "./discovery.js"; export class SpecViewPanel implements vscode.Disposable { @@ -223,15 +223,10 @@ export class SpecViewPanel implements vscode.Disposable { return hit; } visited.push(dir); - try { - const content = await readFile(path.join(dir, "go.mod"), "utf-8"); - const match = /^\s*module\s+(\S+)/m.exec(content); - if (match) { - for (const v of visited) cache.set(v, match[1]); - return match[1]; - } - } catch { - // no go.mod at this level + const mod = await readModulePath(dir); + if (mod) { + for (const v of visited) cache.set(v, mod); + return mod; } const parent = path.dirname(dir); if (parent === dir) break; diff --git a/vscode-gotest/src/testController.ts b/vscode-gotest/src/testController.ts index 6d655d6c..8039d205 100644 --- a/vscode-gotest/src/testController.ts +++ b/vscode-gotest/src/testController.ts @@ -2,7 +2,6 @@ import * as vscode from "vscode"; import * as path from "node:path"; import type { DiscoveryCache } from "./discovery.js"; import { TestResultStore, type TestResult } from "./testResultStore.js"; -export type { TestResult } from "./testResultStore.js"; import { type PathNode, buildPathTrie, collapsePathTrie } from "./pathTrie.js"; export class GoTestController implements vscode.Disposable { diff --git a/vscode-gotest/src/testResultStore.test.ts b/vscode-gotest/src/testResultStore.test.ts index fd029f5e..1ad2b13d 100644 --- a/vscode-gotest/src/testResultStore.test.ts +++ b/vscode-gotest/src/testResultStore.test.ts @@ -39,14 +39,6 @@ describe("TestResultStore", () => { expect(store.get("pkg/suite/b")).toBeDefined(); }); - it("clear: removes all results", () => { - store.record("pkg/suite/a", "pass"); - store.record("pkg/suite/b", "fail"); - store.clear(); - expect(store.size).toBe(0); - expect(store.get("pkg/suite/a")).toBeUndefined(); - }); - it("forEach: iterates all entries", () => { store.record("pkg/suite/a", "pass", 10); store.record("pkg/suite/b", "skip"); diff --git a/vscode-gotest/src/testResultStore.ts b/vscode-gotest/src/testResultStore.ts index 41f12452..05d6ed27 100644 --- a/vscode-gotest/src/testResultStore.ts +++ b/vscode-gotest/src/testResultStore.ts @@ -54,10 +54,6 @@ export class TestResultStore { this.results.delete(itemId); } - clear(): void { - this.results.clear(); - } - forEach(callback: (result: TestResult, itemId: string) => void): void { this.results.forEach((result, id) => callback(result, id)); } diff --git a/vscode-gotest/src/watch.ts b/vscode-gotest/src/watch.ts index 99e57791..ee173ebf 100644 --- a/vscode-gotest/src/watch.ts +++ b/vscode-gotest/src/watch.ts @@ -224,8 +224,6 @@ export class WatchManager implements vscode.Disposable { private watchers = new Map(); private activeRuns = new Map(); private watchRecordIds = new Map(); - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChange: vscode.Event = this._onDidChange.event; private readonly statusBar: vscode.StatusBarItem; constructor( @@ -327,7 +325,6 @@ export class WatchManager implements vscode.Disposable { // Remove from map this.watchers.delete(pkgScope); this.updateStatusBar(); - this._onDidChange.fire(); }, ); @@ -338,7 +335,6 @@ export class WatchManager implements vscode.Disposable { }).id; this.watchRecordIds.set(pkgScope, recordId); this.updateStatusBar(); - this._onDidChange.fire(); } stop(pkgScope: string): void { @@ -361,7 +357,6 @@ export class WatchManager implements vscode.Disposable { } this.updateStatusBar(); - this._onDidChange.fire(); } stopAll(): void { @@ -383,21 +378,15 @@ export class WatchManager implements vscode.Disposable { this.watchers.clear(); this.activeRuns.clear(); this.updateStatusBar(); - this._onDidChange.fire(); } get activeCount(): number { return this.watchers.size; } - isWatching(pkgScope: string): boolean { - return this.watchers.has(pkgScope); - } - dispose(): void { this.stopAll(); this.statusBar.dispose(); - this._onDidChange.dispose(); } private updateStatusBar(): void {