package indexer import ( "testing" "github.com/zzet/gortex/internal/graph" ) // TestRunnerPropagation_ParserStamped — the parser already stamped // Meta["test_runner"] on the file node (JS / TS path). The global pass // must copy it to every is_test function/method in that file, but not // to any production function. func TestRunnerPropagation_ParserStamped(t *testing.T) { g := graph.New() fileID := "src/foo.test.ts" g.AddNode(&graph.Node{ ID: fileID, Kind: graph.KindFile, Name: fileID, FilePath: fileID, Language: "typescript", Meta: map[string]any{"test_runner": "bun-test"}, }) g.AddNode(&graph.Node{ ID: fileID + "::renders", Kind: graph.KindFunction, Name: "renders", FilePath: fileID, Language: "typescript", }) // A production file with a same-shaped function — must NOT be marked. g.AddNode(&graph.Node{ID: "src/prod.ts", Kind: graph.KindFile, FilePath: "src/prod.ts", Language: "typescript"}) g.AddNode(&graph.Node{ID: "src/prod.ts::renders", Kind: graph.KindFunction, Name: "renders", FilePath: "src/prod.ts", Language: "typescript"}) marked, _ := markTestSymbolsAndEmitEdges(g) if marked != 1 { t.Fatalf("marked = %d, want 1", marked) } testFn := g.GetNode(fileID + "::renders") if got, _ := testFn.Meta["test_runner"].(string); got != "bun-test" { t.Fatalf("test_runner on test fn = %q, want %q", got, "bun-test") } if got, _ := testFn.Meta["test_role"].(string); got != "test" { t.Fatalf("test_role on test fn = %q, want %q", got, "test") } prod := g.GetNode("src/prod.ts::renders") if _, ok := prod.Meta["test_runner"]; ok { t.Fatalf("production fn must not carry test_runner: %+v", prod.Meta) } } // TestRunnerPropagation_GoDefault — every Go test file gets `gotest` // without any parser hint, because `go test` is the only Go runner. func TestRunnerPropagation_GoDefault(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkg/foo_test.go", Kind: graph.KindFile, FilePath: "pkg/foo_test.go", Language: "go"}) g.AddNode(&graph.Node{ID: "pkg/foo_test.go::TestFoo", Kind: graph.KindFunction, Name: "TestFoo", FilePath: "pkg/foo_test.go", Language: "go"}) markTestSymbolsAndEmitEdges(g) testFn := g.GetNode("pkg/foo_test.go::TestFoo") if got, _ := testFn.Meta["test_runner"].(string); got != "gotest" { t.Fatalf("Go test_runner = %q, want %q", got, "gotest") } } // TestRunnerPropagation_PythonFromImports — Python files classify by // outgoing import edges. unittest beats pytest only when no pytest // import is present. func TestRunnerPropagation_PythonFromImports(t *testing.T) { cases := []struct { name string importPath string wantRunner string }{ {"pytest import", "pytest", "pytest"}, {"pytest submodule", "pytest.mark", "pytest"}, {"unittest import", "unittest", "unittest"}, {"unittest.mock import", "unittest.mock", "unittest"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := graph.New() fileID := "tests/test_x.py" g.AddNode(&graph.Node{ID: fileID, Kind: graph.KindFile, FilePath: fileID, Language: "python"}) g.AddNode(&graph.Node{ID: fileID + "::test_x", Kind: graph.KindFunction, Name: "test_x", FilePath: fileID, Language: "python"}) g.AddEdge(&graph.Edge{From: fileID, To: "unresolved::import::" + tc.importPath, Kind: graph.EdgeImports, FilePath: fileID, Line: 1}) markTestSymbolsAndEmitEdges(g) fn := g.GetNode(fileID + "::test_x") if got, _ := fn.Meta["test_runner"].(string); got != tc.wantRunner { t.Fatalf("test_runner = %q, want %q", got, tc.wantRunner) } }) } } // TestRunnerPropagation_PythonDefault — a test file with no pytest / // unittest import still defaults to `pytest`, because pytest's // auto-discovery is the de-facto Python runner. func TestRunnerPropagation_PythonDefault(t *testing.T) { g := graph.New() fileID := "tests/test_y.py" g.AddNode(&graph.Node{ID: fileID, Kind: graph.KindFile, FilePath: fileID, Language: "python"}) g.AddNode(&graph.Node{ID: fileID + "::test_y", Kind: graph.KindFunction, Name: "test_y", FilePath: fileID, Language: "python"}) markTestSymbolsAndEmitEdges(g) fn := g.GetNode(fileID + "::test_y") if got, _ := fn.Meta["test_runner"].(string); got != "pytest" { t.Fatalf("Python default test_runner = %q, want pytest", got) } } // TestRunnerPropagation_RubyFileSuffix — Ruby falls back to `_spec.rb` // → rspec, `_test.rb` → minitest with no import signal. func TestRunnerPropagation_RubyFileSuffix(t *testing.T) { cases := []struct{ file, want string }{ {"spec/user_spec.rb", "rspec"}, {"test/user_test.rb", "minitest"}, } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: tc.file, Kind: graph.KindFile, FilePath: tc.file, Language: "ruby"}) g.AddNode(&graph.Node{ID: tc.file + "::test_x", Kind: graph.KindFunction, Name: "test_x", FilePath: tc.file, Language: "ruby"}) markTestSymbolsAndEmitEdges(g) fn := g.GetNode(tc.file + "::test_x") if got, _ := fn.Meta["test_runner"].(string); got != tc.want { t.Fatalf("test_runner = %q, want %q", got, tc.want) } }) } } // TestRunnerPropagation_JSImportEdgeFallback — when the parser didn't // stamp the file (e.g. an older index built before this change), the // global pass still derives the runner from outgoing import edges. func TestRunnerPropagation_JSImportEdgeFallback(t *testing.T) { cases := []struct { name string path string imp string want string }{ {"bun-test via edge", "src/x.test.ts", "bun:test", "bun-test"}, {"vitest via edge", "src/x.test.ts", "vitest", "vitest"}, {"jest via edge", "src/x.test.ts", "@jest/globals", "jest"}, {"mocha via edge", "test/x.js", "mocha", "mocha"}, {"node-test via edge", "test/x.mjs", "node:test", "node-test"}, {"playwright via edge", "e2e/x.spec.ts", "@playwright/test", "playwright"}, {"cypress via edge", "cypress/foo.spec.js", "cypress", "cypress"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: tc.path, Kind: graph.KindFile, FilePath: tc.path, Language: "typescript"}) g.AddNode(&graph.Node{ID: tc.path + "::it_x", Kind: graph.KindFunction, Name: "it_x", FilePath: tc.path, Language: "typescript"}) g.AddEdge(&graph.Edge{From: tc.path, To: "unresolved::import::" + tc.imp, Kind: graph.EdgeImports, FilePath: tc.path, Line: 1}) markTestSymbolsAndEmitEdges(g) fn := g.GetNode(tc.path + "::it_x") if got, _ := fn.Meta["test_runner"].(string); got != tc.want { t.Fatalf("test_runner = %q, want %q", got, tc.want) } }) } } // TestRunnerPropagation_ProductionFileUnchanged — a non-test file with // a test_runner-looking import edge must not be classified. Only test // files (per IsTestFile) get a runner stamped on their functions. func TestRunnerPropagation_ProductionFileUnchanged(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "src/api.ts", Kind: graph.KindFile, FilePath: "src/api.ts", Language: "typescript"}) g.AddNode(&graph.Node{ID: "src/api.ts::serve", Kind: graph.KindFunction, Name: "serve", FilePath: "src/api.ts", Language: "typescript"}) g.AddEdge(&graph.Edge{From: "src/api.ts", To: "unresolved::import::mocha", Kind: graph.EdgeImports, FilePath: "src/api.ts", Line: 1}) markTestSymbolsAndEmitEdges(g) fn := g.GetNode("src/api.ts::serve") if _, ok := fn.Meta["test_runner"]; ok { t.Fatalf("production fn must not carry test_runner: %+v", fn.Meta) } } // TestRunnerPropagation_FileNodeMetaStamped — the file node itself // also gets Meta["test_runner"], so file-level analyses can group // tests by runner without walking functions. func TestRunnerPropagation_FileNodeMetaStamped(t *testing.T) { g := graph.New() fileID := "src/x.test.ts" g.AddNode(&graph.Node{ ID: fileID, Kind: graph.KindFile, FilePath: fileID, Language: "typescript", Meta: map[string]any{"test_runner": "vitest"}, }) markTestSymbolsAndEmitEdges(g) fn := g.GetNode(fileID) if got, _ := fn.Meta["test_runner"].(string); got != "vitest" { t.Fatalf("file node test_runner = %q, want vitest", got) } if v, _ := fn.Meta["is_test_file"].(bool); !v { t.Fatalf("file node is_test_file must be true") } }