diff --git a/src/pkg/parser/strategies/jest/handlers.go b/src/pkg/parser/strategies/jest/handlers.go deleted file mode 100644 index 9b71e03..0000000 --- a/src/pkg/parser/strategies/jest/handlers.go +++ /dev/null @@ -1,138 +0,0 @@ -package jest - -import ( - sitter "github.com/smacker/go-tree-sitter" - - "github.com/specvital/core/domain" - "github.com/specvital/core/parser" -) - -func addTestToTarget(test domain.Test, parentSuite *domain.TestSuite, file *domain.TestFile) { - if parentSuite != nil { - parentSuite.Tests = append(parentSuite.Tests, test) - } else { - file.Tests = append(file.Tests, test) - } -} - -func addSuiteToTarget(suite domain.TestSuite, parentSuite *domain.TestSuite, file *domain.TestFile) { - if parentSuite != nil { - parentSuite.Suites = append(parentSuite.Suites, suite) - } else { - file.Suites = append(file.Suites, suite) - } -} - -func parseCallbackBody(callback *sitter.Node, source []byte, filename string, file *domain.TestFile, suite *domain.TestSuite) { - body := callback.ChildByFieldName("body") - if body != nil { - parseJestNode(body, source, filename, file, suite) - } -} - -func resolveEachNames(template string, testCases []string) []string { - if len(testCases) == 0 { - return []string{template + dynamicCasesSuffix} - } - - names := make([]string, len(testCases)) - for i, testCase := range testCases { - names[i] = formatEachName(template, testCase) - } - - return names -} - -func processEachTests(callNode *sitter.Node, testCases []string, nameTemplate string, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { - names := resolveEachNames(nameTemplate, testCases) - - for _, name := range names { - test := domain.Test{ - Name: name, - Status: status, - Location: parser.GetLocation(callNode, filename), - } - - addTestToTarget(test, parentSuite, file) - } -} - -func processEachSuites(callNode *sitter.Node, testCases []string, nameTemplate string, callback *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { - if callback == nil { - return - } - - names := resolveEachNames(nameTemplate, testCases) - - for _, name := range names { - suite := domain.TestSuite{ - Name: name, - Status: status, - Location: parser.GetLocation(callNode, filename), - } - - parseCallbackBody(callback, source, filename, file, &suite) - addSuiteToTarget(suite, parentSuite, file) - } -} - -func processEachCall(outerCall, innerCall, outerArgs *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { - innerFunc := innerCall.ChildByFieldName("function") - innerArgs := innerCall.ChildByFieldName("arguments") - - if innerFunc == nil || innerArgs == nil { - return - } - - funcName, status := parseFunctionName(innerFunc, source) - if funcName == "" { - return - } - - testCases := extractEachTestCases(innerArgs, source) - nameTemplate := extractTestName(outerArgs, source) - callback := findCallback(outerArgs) - - switch funcName { - case funcDescribe + "." + modifierEach: - processEachSuites(outerCall, testCases, nameTemplate, callback, source, filename, file, currentSuite, status) - case funcIt + "." + modifierEach, funcTest + "." + modifierEach: - processEachTests(outerCall, testCases, nameTemplate, filename, file, currentSuite, status) - } -} - -func processTest(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { - name := extractTestName(args, source) - if name == "" { - return - } - - test := domain.Test{ - Name: name, - Status: status, - Location: parser.GetLocation(callNode, filename), - } - - addTestToTarget(test, parentSuite, file) -} - -func processSuite(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { - name := extractTestName(args, source) - if name == "" { - return - } - - callback := findCallback(args) - if callback == nil { - return - } - - suite := domain.TestSuite{ - Name: name, - Status: status, - Location: parser.GetLocation(callNode, filename), - } - - parseCallbackBody(callback, source, filename, file, &suite) - addSuiteToTarget(suite, parentSuite, file) -} diff --git a/src/pkg/parser/strategies/jest/handlers_test.go b/src/pkg/parser/strategies/jest/handlers_test.go deleted file mode 100644 index 59e3bac..0000000 --- a/src/pkg/parser/strategies/jest/handlers_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package jest - -import ( - "testing" - - "github.com/specvital/core/domain" -) - -func TestAddSuiteToTarget(t *testing.T) { - t.Parallel() - - t.Run("should add suite to file when no parent suite", func(t *testing.T) { - t.Parallel() - - // Given - file := &domain.TestFile{} - suite := domain.TestSuite{Name: "test"} - - // When - addSuiteToTarget(suite, nil, file) - - // Then - if len(file.Suites) != 1 { - t.Fatalf("len(file.Suites) = %d, want 1", len(file.Suites)) - } - if file.Suites[0].Name != "test" { - t.Errorf("Suites[0].Name = %q, want %q", file.Suites[0].Name, "test") - } - }) - - t.Run("should add suite to parent when parent exists", func(t *testing.T) { - t.Parallel() - - // Given - file := &domain.TestFile{} - parent := &domain.TestSuite{Name: "parent"} - suite := domain.TestSuite{Name: "child"} - - // When - addSuiteToTarget(suite, parent, file) - - // Then - if len(file.Suites) != 0 { - t.Error("file.Suites should be empty") - } - if len(parent.Suites) != 1 { - t.Fatalf("len(parent.Suites) = %d, want 1", len(parent.Suites)) - } - if parent.Suites[0].Name != "child" { - t.Errorf("parent.Suites[0].Name = %q, want %q", parent.Suites[0].Name, "child") - } - }) -} - -func TestAddTestToTarget(t *testing.T) { - t.Parallel() - - t.Run("should add test to file when no parent suite", func(t *testing.T) { - t.Parallel() - - // Given - file := &domain.TestFile{} - test := domain.Test{Name: "test"} - - // When - addTestToTarget(test, nil, file) - - // Then - if len(file.Tests) != 1 { - t.Fatalf("len(file.Tests) = %d, want 1", len(file.Tests)) - } - if file.Tests[0].Name != "test" { - t.Errorf("Tests[0].Name = %q, want %q", file.Tests[0].Name, "test") - } - }) - - t.Run("should add test to parent when parent exists", func(t *testing.T) { - t.Parallel() - - // Given - file := &domain.TestFile{} - parent := &domain.TestSuite{Name: "parent"} - test := domain.Test{Name: "test"} - - // When - addTestToTarget(test, parent, file) - - // Then - if len(file.Tests) != 0 { - t.Error("file.Tests should be empty") - } - if len(parent.Tests) != 1 { - t.Fatalf("len(parent.Tests) = %d, want 1", len(parent.Tests)) - } - if parent.Tests[0].Name != "test" { - t.Errorf("parent.Tests[0].Name = %q, want %q", parent.Tests[0].Name, "test") - } - }) -} - -func TestProcessSuite(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - wantSuites int - wantStatus domain.TestStatus - }{ - { - name: "should process describe with pending status", - source: `describe('Suite', () => {});`, - wantSuites: 1, - wantStatus: domain.TestStatusPending, - }, - { - name: "should process describe.skip with skipped status", - source: `describe.skip('Suite', () => {});`, - wantSuites: 1, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should process describe.only with only status", - source: `describe.only('Suite', () => {});`, - wantSuites: 1, - wantStatus: domain.TestStatusOnly, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Suites) != tt.wantSuites { - t.Fatalf("len(Suites) = %d, want %d", len(file.Suites), tt.wantSuites) - } - if file.Suites[0].Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", file.Suites[0].Status, tt.wantStatus) - } - }) - } -} - -func TestProcessTest(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - wantTests int - wantStatus domain.TestStatus - }{ - { - name: "should process it with pending status", - source: `it('test', () => {});`, - wantTests: 1, - wantStatus: domain.TestStatusPending, - }, - { - name: "should process it.skip with skipped status", - source: `it.skip('test', () => {});`, - wantTests: 1, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should process it.only with only status", - source: `it.only('test', () => {});`, - wantTests: 1, - wantStatus: domain.TestStatusOnly, - }, - { - name: "should process test with pending status", - source: `test('test', () => {});`, - wantTests: 1, - wantStatus: domain.TestStatusPending, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Tests) != tt.wantTests { - t.Fatalf("len(Tests) = %d, want %d", len(file.Tests), tt.wantTests) - } - if file.Tests[0].Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", file.Tests[0].Status, tt.wantStatus) - } - }) - } -} - -func TestResolveEachNames(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - template string - testCases []string - want []string - }{ - { - name: "should resolve names with placeholders", - template: "test %s", - testCases: []string{"a", "b"}, - want: []string{"test a", "test b"}, - }, - { - name: "should add dynamic suffix when empty", - template: "test %s", - testCases: []string{}, - want: []string{"test %s (dynamic cases)"}, - }, - { - name: "should handle single case", - template: "case %d", - testCases: []string{"1"}, - want: []string{"case 1"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - got := resolveEachNames(tt.template, tt.testCases) - - // Then - if len(got) != len(tt.want) { - t.Fatalf("len(resolveEachNames()) = %d, want %d", len(got), len(tt.want)) - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("resolveEachNames()[%d] = %q, want %q", i, got[i], tt.want[i]) - } - } - }) - } -} - -func TestProcessEachSuites(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - wantSuites int - wantName string - }{ - { - name: "should create suites for each case", - source: `describe.each([['a'], ['b']])('case %s', () => {});`, - wantSuites: 2, - wantName: "case a", - }, - { - name: "should handle number cases", - source: `describe.each([[1], [2], [3]])('num %d', () => {});`, - wantSuites: 3, - wantName: "num 1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Suites) != tt.wantSuites { - t.Fatalf("len(Suites) = %d, want %d", len(file.Suites), tt.wantSuites) - } - if file.Suites[0].Name != tt.wantName { - t.Errorf("Suites[0].Name = %q, want %q", file.Suites[0].Name, tt.wantName) - } - }) - } -} - -func TestProcessEachTests(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - wantTests int - wantName string - }{ - { - name: "should create tests for each case", - source: `describe('S', () => { it.each([[1], [2]])('val %d', () => {}); });`, - wantTests: 2, - wantName: "val 1", - }, - { - name: "should handle test.each", - source: `describe('S', () => { test.each([['a']])('str %s', () => {}); });`, - wantTests: 1, - wantName: "str a", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Suites) != 1 { - t.Fatalf("len(Suites) = %d, want 1", len(file.Suites)) - } - if len(file.Suites[0].Tests) != tt.wantTests { - t.Fatalf("len(Tests) = %d, want %d", len(file.Suites[0].Tests), tt.wantTests) - } - if file.Suites[0].Tests[0].Name != tt.wantName { - t.Errorf("Tests[0].Name = %q, want %q", file.Suites[0].Tests[0].Name, tt.wantName) - } - }) - } -} diff --git a/src/pkg/parser/strategies/jest/helpers_test.go b/src/pkg/parser/strategies/jest/helpers_test.go deleted file mode 100644 index 2ed4c51..0000000 --- a/src/pkg/parser/strategies/jest/helpers_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package jest - -import ( - "testing" - - "github.com/specvital/core/domain" -) - -func TestParseFunctionName(t *testing.T) { - t.Parallel() - - // parseFunctionName is an internal function, tested indirectly through parse - tests := []struct { - name string - source string - wantStatus domain.TestStatus - }{ - { - name: "should parse describe.skip as skipped", - source: `describe.skip('Suite', () => {});`, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should parse describe.only as only", - source: `describe.only('Suite', () => {});`, - wantStatus: domain.TestStatusOnly, - }, - { - name: "should parse xdescribe as skipped", - source: `xdescribe('Suite', () => {});`, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should parse fdescribe as only", - source: `fdescribe('Suite', () => {});`, - wantStatus: domain.TestStatusOnly, - }, - { - name: "should parse it.skip as skipped", - source: `it.skip('test', () => {});`, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should parse it.only as only", - source: `it.only('test', () => {});`, - wantStatus: domain.TestStatusOnly, - }, - { - name: "should parse xit as skipped", - source: `xit('test', () => {});`, - wantStatus: domain.TestStatusSkipped, - }, - { - name: "should parse fit as only", - source: `fit('test', () => {});`, - wantStatus: domain.TestStatusOnly, - }, - { - name: "should parse test.todo as skipped", - source: `test.todo('test');`, - wantStatus: domain.TestStatusSkipped, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - - var status domain.TestStatus - if len(file.Suites) > 0 { - status = file.Suites[0].Status - } else if len(file.Tests) > 0 { - status = file.Tests[0].Status - } else { - t.Fatal("no suites or tests found") - } - - if status != tt.wantStatus { - t.Errorf("status = %q, want %q", status, tt.wantStatus) - } - }) - } -} - -func TestUnquoteString(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - }{ - { - name: "should unquote double quotes", - input: `"hello"`, - want: "hello", - }, - { - name: "should unquote single quotes", - input: `'hello'`, - want: "hello", - }, - { - name: "should unquote backticks", - input: "`hello`", - want: "hello", - }, - { - name: "should return short string as-is", - input: "a", - want: "a", - }, - { - name: "should return unquoted string as-is", - input: "hello", - want: "hello", - }, - { - name: "should handle mismatched quotes", - input: `"hello'`, - want: `"hello'`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - got := unquoteString(tt.input) - - // Then - if got != tt.want { - t.Errorf("unquoteString(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestExtractTestName(t *testing.T) { - t.Parallel() - - // extractTestName is an internal function, tested indirectly through parse - tests := []struct { - name string - source string - want string - }{ - { - name: "should extract single-quoted name", - source: `it('single quoted', () => {});`, - want: "single quoted", - }, - { - name: "should extract double-quoted name", - source: `it("double quoted", () => {});`, - want: "double quoted", - }, - { - name: "should extract template string name", - source: "it(`template string`, () => {});", - want: "template string", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Tests) != 1 { - t.Fatalf("len(Tests) = %d, want 1", len(file.Tests)) - } - if file.Tests[0].Name != tt.want { - t.Errorf("Name = %q, want %q", file.Tests[0].Name, tt.want) - } - }) - } -} - -func TestFindCallback(t *testing.T) { - t.Parallel() - - // findCallback is an internal function, tested indirectly through parse - tests := []struct { - name string - source string - wantSuites int - wantTests int - }{ - { - name: "should find arrow function callback", - source: `describe('Suite', () => { it('test', () => {}); });`, - wantSuites: 1, - wantTests: 0, - }, - { - name: "should find function expression callback", - source: `describe('Suite', function() { it('test', function() {}); });`, - wantSuites: 1, - wantTests: 0, - }, - { - name: "should ignore describe without callback", - source: `describe('NoCallback');`, - wantSuites: 0, - wantTests: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Suites) != tt.wantSuites { - t.Errorf("len(Suites) = %d, want %d", len(file.Suites), tt.wantSuites) - } - if len(file.Tests) != tt.wantTests { - t.Errorf("len(Tests) = %d, want %d", len(file.Tests), tt.wantTests) - } - }) - } -} - -func TestExtractEachTestCases(t *testing.T) { - t.Parallel() - - // extractEachTestCases is an internal function, tested indirectly through parse - tests := []struct { - name string - source string - wantCount int - wantFirst string - }{ - { - name: "should extract array of arrays", - source: `describe('S', () => { it.each([[1], [2], [3]])('test %d', () => {}); });`, - wantCount: 3, - wantFirst: "test 1", - }, - { - name: "should extract string values", - source: `describe('S', () => { it.each(['foo', 'bar'])('test %s', () => {}); });`, - wantCount: 2, - wantFirst: "test foo", - }, - { - name: "should extract number values", - source: `describe('S', () => { it.each([1, 2])('test %d', () => {}); });`, - wantCount: 2, - wantFirst: "test 1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - file, err := parse([]byte(tt.source), "test.ts") - - // Then - if err != nil { - t.Fatalf("parse() error = %v", err) - } - if len(file.Suites) != 1 { - t.Fatalf("len(Suites) = %d, want 1", len(file.Suites)) - } - if len(file.Suites[0].Tests) != tt.wantCount { - t.Fatalf("len(Tests) = %d, want %d", len(file.Suites[0].Tests), tt.wantCount) - } - if file.Suites[0].Tests[0].Name != tt.wantFirst { - t.Errorf("Tests[0].Name = %q, want %q", file.Suites[0].Tests[0].Name, tt.wantFirst) - } - }) - } -} - -func TestFormatEachName(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - template string - data string - want string - }{ - { - name: "should replace %s placeholder", - template: "test %s", - data: "value", - want: "test value", - }, - { - name: "should replace %d placeholder", - template: "test %d", - data: "123", - want: "test 123", - }, - { - name: "should replace %p placeholder", - template: "test %p", - data: "data", - want: "test data", - }, - { - name: "should replace first placeholder only", - template: "test %s %s", - data: "first", - want: "test first %s", - }, - { - name: "should return template if no placeholder", - template: "no placeholder", - data: "data", - want: "no placeholder", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // When - got := formatEachName(tt.template, tt.data) - - // Then - if got != tt.want { - t.Errorf("formatEachName(%q, %q) = %q, want %q", tt.template, tt.data, got, tt.want) - } - }) - } -} diff --git a/src/pkg/parser/strategies/jest/jest.go b/src/pkg/parser/strategies/jest/jest.go index 04dcfa8..d5c3a6d 100644 --- a/src/pkg/parser/strategies/jest/jest.go +++ b/src/pkg/parser/strategies/jest/jest.go @@ -1,26 +1,13 @@ package jest import ( - "path/filepath" - "regexp" - "slices" - "strings" + "context" "github.com/specvital/core/domain" "github.com/specvital/core/parser/strategies" + "github.com/specvital/core/parser/strategies/shared/jstest" ) -var supportedExtensions = map[string]bool{ - ".ts": true, - ".tsx": true, - ".js": true, - ".jsx": true, -} - -const testsDir = "__tests__" - -var jestFilePattern = regexp.MustCompile(`\.(test|spec)\.(ts|tsx|js|jsx)$`) - type Strategy struct{} func NewStrategy() *Strategy { @@ -44,28 +31,9 @@ func (s *Strategy) Languages() []domain.Language { } func (s *Strategy) CanHandle(filename string, _ []byte) bool { - if jestFilePattern.MatchString(filename) { - return true - } - - if isInTestsDirectory(filename) { - return hasSupportedExtension(filename) - } - - return false -} - -func isInTestsDirectory(filename string) bool { - normalizedPath := filepath.ToSlash(filename) - parts := strings.Split(normalizedPath, "/") - return slices.Contains(parts, testsDir) -} - -func hasSupportedExtension(filename string) bool { - ext := filepath.Ext(filename) - return supportedExtensions[ext] + return jstest.IsTestFile(filename) } -func (s *Strategy) Parse(source []byte, filename string) (*domain.TestFile, error) { - return parse(source, filename) +func (s *Strategy) Parse(ctx context.Context, source []byte, filename string) (*domain.TestFile, error) { + return parse(ctx, source, filename) } diff --git a/src/pkg/parser/strategies/jest/jest_test.go b/src/pkg/parser/strategies/jest/jest_test.go index 70bf1e3..ec2d824 100644 --- a/src/pkg/parser/strategies/jest/jest_test.go +++ b/src/pkg/parser/strategies/jest/jest_test.go @@ -1,6 +1,7 @@ package jest import ( + "context" "testing" "github.com/specvital/core/domain" @@ -169,7 +170,7 @@ func TestStrategy_Parse(t *testing.T) { t.Parallel() // When - file, err := s.Parse([]byte(tt.source), tt.filename) + file, err := s.Parse(context.Background(), []byte(tt.source), tt.filename) // Then if err != nil { @@ -273,7 +274,7 @@ func TestStrategy_Parse_Modifiers(t *testing.T) { t.Parallel() // When - file, err := s.Parse([]byte(tt.source), "test.ts") + file, err := s.Parse(context.Background(), []byte(tt.source), "test.ts") // Then if err != nil { @@ -347,7 +348,7 @@ func TestStrategy_Parse_Each(t *testing.T) { t.Parallel() // When - file, err := s.Parse([]byte(tt.source), "test.ts") + file, err := s.Parse(context.Background(), []byte(tt.source), "test.ts") // Then if err != nil { @@ -386,7 +387,7 @@ func TestStrategy_Parse_Location(t *testing.T) { });` // When - file, err := s.Parse([]byte(source), "user.test.ts") + file, err := s.Parse(context.Background(), []byte(source), "user.test.ts") // Then if err != nil { diff --git a/src/pkg/parser/strategies/jest/parser.go b/src/pkg/parser/strategies/jest/parser.go index c91695b..a33857e 100644 --- a/src/pkg/parser/strategies/jest/parser.go +++ b/src/pkg/parser/strategies/jest/parser.go @@ -2,88 +2,13 @@ package jest import ( "context" - "fmt" - "path/filepath" - - sitter "github.com/smacker/go-tree-sitter" "github.com/specvital/core/domain" - "github.com/specvital/core/parser" + "github.com/specvital/core/parser/strategies/shared/jstest" ) -func detectLanguage(filename string) domain.Language { - ext := filepath.Ext(filename) - switch ext { - case ".js", ".jsx": - return domain.LanguageJavaScript - default: - return domain.LanguageTypeScript - } -} - -func processCallExpression(node *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { - funcNode := node.ChildByFieldName("function") - if funcNode == nil { - return - } - - args := node.ChildByFieldName("arguments") - if args == nil { - return - } - - if funcNode.Type() == "call_expression" { - processEachCall(node, funcNode, args, source, filename, file, currentSuite) - return - } - - funcName, status := parseFunctionName(funcNode, source) - if funcName == "" { - return - } - - switch funcName { - case funcDescribe: - processSuite(node, args, source, filename, file, currentSuite, status) - case funcIt, funcTest: - processTest(node, args, source, filename, file, currentSuite, status) - } -} - -func parseJestNode(node *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { - for i := 0; i < int(node.ChildCount()); i++ { - child := node.Child(i) - - switch child.Type() { - case "expression_statement": - if expr := parser.FindChildByType(child, "call_expression"); expr != nil { - processCallExpression(expr, source, filename, file, currentSuite) - } - default: - parseJestNode(child, source, filename, file, currentSuite) - } - } -} - -func parse(source []byte, filename string) (*domain.TestFile, error) { - lang := detectLanguage(filename) - - p := parser.NewTSParser(lang) - - tree, err := p.Parse(context.Background(), source) - if err != nil { - return nil, fmt.Errorf("failed to parse %s: %w", filename, err) - } - defer tree.Close() - root := tree.RootNode() - - testFile := &domain.TestFile{ - Path: filename, - Language: lang, - Framework: frameworkName, - } - - parseJestNode(root, source, filename, testFile, nil) +const frameworkName = "jest" - return testFile, nil +func parse(ctx context.Context, source []byte, filename string) (*domain.TestFile, error) { + return jstest.Parse(ctx, source, filename, frameworkName) } diff --git a/src/pkg/parser/strategies/jest/parser_test.go b/src/pkg/parser/strategies/jest/parser_test.go index b10fb77..dbaeb4d 100644 --- a/src/pkg/parser/strategies/jest/parser_test.go +++ b/src/pkg/parser/strategies/jest/parser_test.go @@ -1,9 +1,11 @@ package jest import ( + "context" "testing" "github.com/specvital/core/domain" + "github.com/specvital/core/parser/strategies/shared/jstest" ) func TestDetectLanguage(t *testing.T) { @@ -46,11 +48,11 @@ func TestDetectLanguage(t *testing.T) { t.Parallel() // When - got := detectLanguage(tt.filename) + got := jstest.DetectLanguage(tt.filename) // Then if got != tt.want { - t.Errorf("detectLanguage(%q) = %q, want %q", tt.filename, got, tt.want) + t.Errorf("jstest.DetectLanguage(%q) = %q, want %q", tt.filename, got, tt.want) } }) } @@ -102,7 +104,7 @@ func TestParse(t *testing.T) { t.Parallel() // When - file, err := parse([]byte(tt.source), tt.filename) + file, err := parse(context.Background(), []byte(tt.source), tt.filename) // Then if tt.wantErr { @@ -172,7 +174,7 @@ func TestParseJestNode(t *testing.T) { t.Parallel() // When - file, err := parse([]byte(tt.source), "test.ts") + file, err := parse(context.Background(), []byte(tt.source), "test.ts") // Then if err != nil { diff --git a/src/pkg/parser/strategies/registry.go b/src/pkg/parser/strategies/registry.go index d3c8be6..f64ba58 100644 --- a/src/pkg/parser/strategies/registry.go +++ b/src/pkg/parser/strategies/registry.go @@ -1,6 +1,7 @@ package strategies import ( + "context" "sort" "sync" @@ -16,7 +17,7 @@ type Strategy interface { Priority() int Languages() []domain.Language CanHandle(filename string, content []byte) bool - Parse(source []byte, filename string) (*domain.TestFile, error) + Parse(ctx context.Context, source []byte, filename string) (*domain.TestFile, error) } type Registry struct { diff --git a/src/pkg/parser/strategies/registry_test.go b/src/pkg/parser/strategies/registry_test.go index c8a5497..fe63c30 100644 --- a/src/pkg/parser/strategies/registry_test.go +++ b/src/pkg/parser/strategies/registry_test.go @@ -1,6 +1,7 @@ package strategies import ( + "context" "testing" "github.com/specvital/core/domain" @@ -27,7 +28,7 @@ func (m *mockStrategy) CanHandle(string, []byte) bool { return m.canHandleOk } -func (m *mockStrategy) Parse([]byte, string) (*domain.TestFile, error) { +func (m *mockStrategy) Parse(_ context.Context, _ []byte, _ string) (*domain.TestFile, error) { return &domain.TestFile{Framework: m.name}, nil } diff --git a/src/pkg/parser/strategies/shared/jstest/constants.go b/src/pkg/parser/strategies/shared/jstest/constants.go new file mode 100644 index 0000000..c955051 --- /dev/null +++ b/src/pkg/parser/strategies/shared/jstest/constants.go @@ -0,0 +1,81 @@ +package jstest + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/specvital/core/domain" +) + +const ( + FuncDescribe = "describe" + FuncIt = "it" + FuncTest = "test" + + ModifierSkip = "skip" + ModifierOnly = "only" + ModifierEach = "each" + ModifierTodo = "todo" + + DynamicCasesSuffix = " (dynamic cases)" +) + +var SkippedFunctionAliases = map[string]string{ + "xdescribe": FuncDescribe, + "xit": FuncIt, + "xtest": FuncTest, +} + +var FocusedFunctionAliases = map[string]string{ + "fdescribe": FuncDescribe, + "fit": FuncIt, +} + +var JestPlaceholderPattern = regexp.MustCompile(`%[sdpji#%]`) + +var SupportedExtensions = map[string]bool{ + ".ts": true, + ".tsx": true, + ".js": true, + ".jsx": true, +} + +var testFilePattern = regexp.MustCompile(`\.(test|spec)\.(ts|tsx|js|jsx)$`) + +const testsDir = "__tests__" + +func IsTestFile(filename string) bool { + if testFilePattern.MatchString(filename) { + return true + } + + if isInTestsDirectory(filename) { + return hasSupportedExtension(filename) + } + + return false +} + +func isInTestsDirectory(filename string) bool { + normalizedPath := filepath.ToSlash(filename) + parts := strings.Split(normalizedPath, "/") + return slices.Contains(parts, testsDir) +} + +func hasSupportedExtension(filename string) bool { + ext := filepath.Ext(filename) + return SupportedExtensions[ext] +} + +func ParseModifierStatus(modifier string) domain.TestStatus { + switch modifier { + case ModifierSkip, ModifierTodo: + return domain.TestStatusSkipped + case ModifierOnly: + return domain.TestStatusOnly + default: + return domain.TestStatusPending + } +} diff --git a/src/pkg/parser/strategies/jest/helpers.go b/src/pkg/parser/strategies/shared/jstest/helpers.go similarity index 52% rename from src/pkg/parser/strategies/jest/helpers.go rename to src/pkg/parser/strategies/shared/jstest/helpers.go index 67dae4a..8cee42c 100644 --- a/src/pkg/parser/strategies/jest/helpers.go +++ b/src/pkg/parser/strategies/shared/jstest/helpers.go @@ -1,7 +1,6 @@ -package jest +package jstest import ( - "regexp" "strconv" "strings" @@ -11,57 +10,30 @@ import ( "github.com/specvital/core/parser" ) -const ( - frameworkName = "jest" - - funcDescribe = "describe" - funcIt = "it" - funcTest = "test" - - modifierSkip = "skip" - modifierOnly = "only" - modifierEach = "each" - modifierTodo = "todo" - - dynamicCasesSuffix = " (dynamic cases)" -) - -var skippedFunctionAliases = map[string]string{ - "xdescribe": funcDescribe, - "xit": funcIt, - "xtest": funcTest, -} - -var focusedFunctionAliases = map[string]string{ - "fdescribe": funcDescribe, - "fit": funcIt, -} - -var jestPlaceholderPattern = regexp.MustCompile(`%[sdpji#%]`) - -func unquoteString(text string) string { +func UnquoteString(text string) string { if len(text) < 2 { return text } - // Handle template literals, which are not supported by strconv.Unquote. if text[0] == '`' && text[len(text)-1] == '`' { return text[1 : len(text)-1] } - // Handle single-quoted strings (JavaScript style). - // strconv.Unquote only handles double-quoted strings and Go-style rune literals. + // Handle single-quoted JavaScript strings. + // Go's strconv.Unquote only handles double-quoted strings, so we need to + // convert single-quoted strings to double-quoted format first: + // 1. Remove outer single quotes and get the inner content + // 2. Unescape JavaScript's escaped single quotes (\' -> ') + // 3. Escape any double quotes for Go's strconv.Unquote + // 4. Wrap in double quotes and parse with strconv.Unquote if text[0] == '\'' && text[len(text)-1] == '\'' { inner := text[1 : len(text)-1] - // Handle escaped single quotes before converting to a double-quoted string. inner = strings.ReplaceAll(inner, `\'`, `'`) - // Escape any unescaped double quotes in the content for strconv.Unquote escaped := strings.ReplaceAll(inner, `"`, `\"`) converted := `"` + escaped + `"` if s, err := strconv.Unquote(converted); err == nil { return s } - // Fallback to original text on failure to avoid returning a partially processed string. return text } @@ -72,11 +44,11 @@ func unquoteString(text string) string { return text } -func formatEachName(template, data string) string { +func FormatEachName(template, data string) string { args := strings.Split(data, ", ") argIndex := 0 - result := jestPlaceholderPattern.ReplaceAllStringFunc(template, func(match string) string { + result := JestPlaceholderPattern.ReplaceAllStringFunc(template, func(match string) string { if match == "%%" { return "%" } @@ -91,14 +63,14 @@ func formatEachName(template, data string) string { return result } -func extractArrayContent(node *sitter.Node, source []byte) string { +func ExtractArrayContent(node *sitter.Node, source []byte) string { var parts []string for i := 0; i < int(node.ChildCount()); i++ { child := node.Child(i) switch child.Type() { case "string": - parts = append(parts, unquoteString(parser.GetNodeText(child, source))) + parts = append(parts, UnquoteString(parser.GetNodeText(child, source))) case "number": parts = append(parts, parser.GetNodeText(child, source)) } @@ -107,16 +79,16 @@ func extractArrayContent(node *sitter.Node, source []byte) string { return strings.Join(parts, ", ") } -func extractArrayElements(arrayNode *sitter.Node, source []byte) []string { +func ExtractArrayElements(arrayNode *sitter.Node, source []byte) []string { var elements []string for i := 0; i < int(arrayNode.ChildCount()); i++ { elem := arrayNode.Child(i) switch elem.Type() { case "array": - elements = append(elements, extractArrayContent(elem, source)) + elements = append(elements, ExtractArrayContent(elem, source)) case "string": - elements = append(elements, unquoteString(parser.GetNodeText(elem, source))) + elements = append(elements, UnquoteString(parser.GetNodeText(elem, source))) case "number": elements = append(elements, parser.GetNodeText(elem, source)) } @@ -125,13 +97,13 @@ func extractArrayElements(arrayNode *sitter.Node, source []byte) []string { return elements } -func extractEachTestCases(args *sitter.Node, source []byte) []string { +func ExtractEachTestCases(args *sitter.Node, source []byte) []string { var cases []string for i := 0; i < int(args.ChildCount()); i++ { child := args.Child(i) if child.Type() == "array" { - cases = extractArrayElements(child, source) + cases = ExtractArrayElements(child, source) break } } @@ -139,7 +111,7 @@ func extractEachTestCases(args *sitter.Node, source []byte) []string { return cases } -func findCallback(args *sitter.Node) *sitter.Node { +func FindCallback(args *sitter.Node) *sitter.Node { for i := 0; i < int(args.ChildCount()); i++ { child := args.Child(i) switch child.Type() { @@ -150,45 +122,34 @@ func findCallback(args *sitter.Node) *sitter.Node { return nil } -func extractTestName(args *sitter.Node, source []byte) string { +func ExtractTestName(args *sitter.Node, source []byte) string { for i := 0; i < int(args.ChildCount()); i++ { child := args.Child(i) switch child.Type() { case "string", "template_string": - return unquoteString(parser.GetNodeText(child, source)) + return UnquoteString(parser.GetNodeText(child, source)) } } return "" } -func parseModifierStatus(modifier string) domain.TestStatus { - switch modifier { - case modifierSkip, modifierTodo: - return domain.TestStatusSkipped - case modifierOnly: - return domain.TestStatusOnly - default: - return domain.TestStatusPending - } -} - -func parseSimpleMemberExpression(obj, prop *sitter.Node, source []byte) (string, domain.TestStatus) { +func ParseSimpleMemberExpression(obj, prop *sitter.Node, source []byte) (string, domain.TestStatus) { objName := parser.GetNodeText(obj, source) propName := parser.GetNodeText(prop, source) switch propName { - case modifierSkip, modifierTodo: + case ModifierSkip, ModifierTodo: return objName, domain.TestStatusSkipped - case modifierOnly: + case ModifierOnly: return objName, domain.TestStatusOnly - case modifierEach: - return objName + "." + modifierEach, domain.TestStatusPending + case ModifierEach: + return objName + "." + ModifierEach, domain.TestStatusPending default: return "", domain.TestStatusPending } } -func parseNestedMemberExpression(obj, prop *sitter.Node, source []byte) (string, domain.TestStatus) { +func ParseNestedMemberExpression(obj, prop *sitter.Node, source []byte) (string, domain.TestStatus) { innerObj := obj.ChildByFieldName("object") innerProp := obj.ChildByFieldName("property") @@ -200,16 +161,16 @@ func parseNestedMemberExpression(obj, prop *sitter.Node, source []byte) (string, middleProp := parser.GetNodeText(innerProp, source) propName := parser.GetNodeText(prop, source) - status := parseModifierStatus(middleProp) + status := ParseModifierStatus(middleProp) - if propName == modifierEach { - return objName + "." + modifierEach, status + if propName == ModifierEach { + return objName + "." + ModifierEach, status } return "", status } -func parseMemberExpressionFunction(node *sitter.Node, source []byte) (string, domain.TestStatus) { +func ParseMemberExpressionFunction(node *sitter.Node, source []byte) (string, domain.TestStatus) { obj := node.ChildByFieldName("object") prop := node.ChildByFieldName("property") @@ -218,32 +179,32 @@ func parseMemberExpressionFunction(node *sitter.Node, source []byte) (string, do } if obj.Type() == "member_expression" { - return parseNestedMemberExpression(obj, prop, source) + return ParseNestedMemberExpression(obj, prop, source) } - return parseSimpleMemberExpression(obj, prop, source) + return ParseSimpleMemberExpression(obj, prop, source) } -func parseIdentifierFunction(node *sitter.Node, source []byte) (string, domain.TestStatus) { +func ParseIdentifierFunction(node *sitter.Node, source []byte) (string, domain.TestStatus) { name := parser.GetNodeText(node, source) - if baseName, ok := skippedFunctionAliases[name]; ok { + if baseName, ok := SkippedFunctionAliases[name]; ok { return baseName, domain.TestStatusSkipped } - if baseName, ok := focusedFunctionAliases[name]; ok { + if baseName, ok := FocusedFunctionAliases[name]; ok { return baseName, domain.TestStatusOnly } return name, domain.TestStatusPending } -func parseFunctionName(node *sitter.Node, source []byte) (string, domain.TestStatus) { +func ParseFunctionName(node *sitter.Node, source []byte) (string, domain.TestStatus) { switch node.Type() { case "identifier": - return parseIdentifierFunction(node, source) + return ParseIdentifierFunction(node, source) case "member_expression": - return parseMemberExpressionFunction(node, source) + return ParseMemberExpressionFunction(node, source) default: return "", domain.TestStatusPending } diff --git a/src/pkg/parser/strategies/shared/jstest/helpers_test.go b/src/pkg/parser/strategies/shared/jstest/helpers_test.go new file mode 100644 index 0000000..f61f5dc --- /dev/null +++ b/src/pkg/parser/strategies/shared/jstest/helpers_test.go @@ -0,0 +1,262 @@ +package jstest + +import ( + "testing" + + "github.com/specvital/core/domain" +) + +func TestUnquoteString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + { + name: "should unquote double quotes", + input: `"hello"`, + want: "hello", + }, + { + name: "should unquote single quotes", + input: `'hello'`, + want: "hello", + }, + { + name: "should unquote backticks", + input: "`hello`", + want: "hello", + }, + { + name: "should return short string as-is", + input: "a", + want: "a", + }, + { + name: "should return unquoted string as-is", + input: "hello", + want: "hello", + }, + { + name: "should handle mismatched quotes", + input: `"hello'`, + want: `"hello'`, + }, + { + name: "should handle escaped single quotes", + input: `'it\'s working'`, + want: "it's working", + }, + { + name: "should handle escaped double quotes in double quoted string", + input: `"say \"hello\""`, + want: `say "hello"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := UnquoteString(tt.input) + + if got != tt.want { + t.Errorf("UnquoteString(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestFormatEachName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + template string + data string + want string + }{ + { + name: "should replace %s placeholder", + template: "test %s", + data: "value", + want: "test value", + }, + { + name: "should replace %d placeholder", + template: "test %d", + data: "123", + want: "test 123", + }, + { + name: "should replace %p placeholder", + template: "test %p", + data: "data", + want: "test data", + }, + { + name: "should replace multiple placeholders", + template: "test %s and %d", + data: "foo, 42", + want: "test foo and 42", + }, + { + name: "should keep unreplaced placeholders", + template: "test %s %s %s", + data: "first, second", + want: "test first second %s", + }, + { + name: "should handle %% escape", + template: "100%% complete", + data: "", + want: "100% complete", + }, + { + name: "should return template if no placeholder", + template: "no placeholder", + data: "data", + want: "no placeholder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := FormatEachName(tt.template, tt.data) + + if got != tt.want { + t.Errorf("FormatEachName(%q, %q) = %q, want %q", tt.template, tt.data, got, tt.want) + } + }) + } +} + +func TestParseModifierStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + modifier string + want domain.TestStatus + }{ + { + name: "should return skipped for skip", + modifier: "skip", + want: domain.TestStatusSkipped, + }, + { + name: "should return skipped for todo", + modifier: "todo", + want: domain.TestStatusSkipped, + }, + { + name: "should return only for only", + modifier: "only", + want: domain.TestStatusOnly, + }, + { + name: "should return pending for unknown", + modifier: "unknown", + want: domain.TestStatusPending, + }, + { + name: "should return pending for empty", + modifier: "", + want: domain.TestStatusPending, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ParseModifierStatus(tt.modifier) + + if got != tt.want { + t.Errorf("ParseModifierStatus(%q) = %q, want %q", tt.modifier, got, tt.want) + } + }) + } +} + +func TestSkippedFunctionAliases(t *testing.T) { + t.Parallel() + + tests := []struct { + alias string + want string + }{ + {"xdescribe", FuncDescribe}, + {"xit", FuncIt}, + {"xtest", FuncTest}, + } + + for _, tt := range tests { + t.Run(tt.alias, func(t *testing.T) { + t.Parallel() + + got, ok := SkippedFunctionAliases[tt.alias] + if !ok { + t.Fatalf("SkippedFunctionAliases[%q] not found", tt.alias) + } + if got != tt.want { + t.Errorf("SkippedFunctionAliases[%q] = %q, want %q", tt.alias, got, tt.want) + } + }) + } +} + +func TestFocusedFunctionAliases(t *testing.T) { + t.Parallel() + + tests := []struct { + alias string + want string + }{ + {"fdescribe", FuncDescribe}, + {"fit", FuncIt}, + } + + for _, tt := range tests { + t.Run(tt.alias, func(t *testing.T) { + t.Parallel() + + got, ok := FocusedFunctionAliases[tt.alias] + if !ok { + t.Fatalf("FocusedFunctionAliases[%q] not found", tt.alias) + } + if got != tt.want { + t.Errorf("FocusedFunctionAliases[%q] = %q, want %q", tt.alias, got, tt.want) + } + }) + } +} + +func TestSupportedExtensions(t *testing.T) { + t.Parallel() + + supported := []string{".ts", ".tsx", ".js", ".jsx"} + unsupported := []string{".go", ".py", ".rb", ".java"} + + for _, ext := range supported { + t.Run("should support "+ext, func(t *testing.T) { + t.Parallel() + if !SupportedExtensions[ext] { + t.Errorf("SupportedExtensions[%q] = false, want true", ext) + } + }) + } + + for _, ext := range unsupported { + t.Run("should not support "+ext, func(t *testing.T) { + t.Parallel() + if SupportedExtensions[ext] { + t.Errorf("SupportedExtensions[%q] = true, want false", ext) + } + }) + } +} diff --git a/src/pkg/parser/strategies/shared/jstest/parser.go b/src/pkg/parser/strategies/shared/jstest/parser.go new file mode 100644 index 0000000..db29747 --- /dev/null +++ b/src/pkg/parser/strategies/shared/jstest/parser.go @@ -0,0 +1,231 @@ +package jstest + +import ( + "context" + "fmt" + "path/filepath" + + sitter "github.com/smacker/go-tree-sitter" + + "github.com/specvital/core/domain" + "github.com/specvital/core/parser" +) + +// DetectLanguage determines the programming language based on file extension. +func DetectLanguage(filename string) domain.Language { + ext := filepath.Ext(filename) + switch ext { + case ".js", ".jsx": + return domain.LanguageJavaScript + default: + return domain.LanguageTypeScript + } +} + +// AddTestToTarget adds a test to the appropriate parent (suite or file). +func AddTestToTarget(test domain.Test, parentSuite *domain.TestSuite, file *domain.TestFile) { + if parentSuite != nil { + parentSuite.Tests = append(parentSuite.Tests, test) + } else { + file.Tests = append(file.Tests, test) + } +} + +// AddSuiteToTarget adds a suite to the appropriate parent (suite or file). +func AddSuiteToTarget(suite domain.TestSuite, parentSuite *domain.TestSuite, file *domain.TestFile) { + if parentSuite != nil { + parentSuite.Suites = append(parentSuite.Suites, suite) + } else { + file.Suites = append(file.Suites, suite) + } +} + +// ResolveEachNames generates test names from a template and test cases. +func ResolveEachNames(template string, testCases []string) []string { + if len(testCases) == 0 { + return []string{template + DynamicCasesSuffix} + } + + names := make([]string, len(testCases)) + for i, testCase := range testCases { + names[i] = FormatEachName(template, testCase) + } + + return names +} + +// ParseCallbackBody parses the body of a callback function. +func ParseCallbackBody(callback *sitter.Node, source []byte, filename string, file *domain.TestFile, suite *domain.TestSuite) { + body := callback.ChildByFieldName("body") + if body != nil { + ParseNode(body, source, filename, file, suite) + } +} + +// ProcessTest creates a test from a call expression. +func ProcessTest(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + name := ExtractTestName(args, source) + if name == "" { + return + } + + test := domain.Test{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + AddTestToTarget(test, parentSuite, file) +} + +// ProcessSuite creates a test suite from a call expression. +// Handles both regular suites with callbacks and pending suites without callbacks. +func ProcessSuite(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + name := ExtractTestName(args, source) + if name == "" { + return + } + + suite := domain.TestSuite{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + if callback := FindCallback(args); callback != nil { + ParseCallbackBody(callback, source, filename, file, &suite) + } + + AddSuiteToTarget(suite, parentSuite, file) +} + +// ProcessEachTests creates multiple tests from a .each() call. +func ProcessEachTests(callNode *sitter.Node, testCases []string, nameTemplate string, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + names := ResolveEachNames(nameTemplate, testCases) + + for _, name := range names { + test := domain.Test{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + AddTestToTarget(test, parentSuite, file) + } +} + +// ProcessEachSuites creates multiple suites from a describe.each() call. +func ProcessEachSuites(callNode *sitter.Node, testCases []string, nameTemplate string, callback *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + if callback == nil { + return + } + + names := ResolveEachNames(nameTemplate, testCases) + + for _, name := range names { + suite := domain.TestSuite{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + ParseCallbackBody(callback, source, filename, file, &suite) + AddSuiteToTarget(suite, parentSuite, file) + } +} + +// ProcessEachCall handles .each() call patterns for both describe and test/it. +func ProcessEachCall(outerCall, innerCall, outerArgs *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { + innerFunc := innerCall.ChildByFieldName("function") + innerArgs := innerCall.ChildByFieldName("arguments") + + if innerFunc == nil || innerArgs == nil { + return + } + + funcName, status := ParseFunctionName(innerFunc, source) + if funcName == "" { + return + } + + testCases := ExtractEachTestCases(innerArgs, source) + nameTemplate := ExtractTestName(outerArgs, source) + callback := FindCallback(outerArgs) + + switch funcName { + case FuncDescribe + "." + ModifierEach: + ProcessEachSuites(outerCall, testCases, nameTemplate, callback, source, filename, file, currentSuite, status) + case FuncIt + "." + ModifierEach, FuncTest + "." + ModifierEach: + ProcessEachTests(outerCall, testCases, nameTemplate, filename, file, currentSuite, status) + } +} + +// ProcessCallExpression processes a call expression node to extract test/suite definitions. +func ProcessCallExpression(node *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { + funcNode := node.ChildByFieldName("function") + if funcNode == nil { + return + } + + args := node.ChildByFieldName("arguments") + if args == nil { + return + } + + if funcNode.Type() == "call_expression" { + ProcessEachCall(node, funcNode, args, source, filename, file, currentSuite) + return + } + + funcName, status := ParseFunctionName(funcNode, source) + if funcName == "" { + return + } + + switch funcName { + case FuncDescribe: + ProcessSuite(node, args, source, filename, file, currentSuite, status) + case FuncIt, FuncTest: + ProcessTest(node, args, source, filename, file, currentSuite, status) + } +} + +// ParseNode recursively traverses the AST to find and process test definitions. +func ParseNode(node *sitter.Node, source []byte, filename string, file *domain.TestFile, currentSuite *domain.TestSuite) { + for i := 0; i < int(node.ChildCount()); i++ { + child := node.Child(i) + + switch child.Type() { + case "expression_statement": + if expr := parser.FindChildByType(child, "call_expression"); expr != nil { + ProcessCallExpression(expr, source, filename, file, currentSuite) + } + default: + ParseNode(child, source, filename, file, currentSuite) + } + } +} + +// Parse is the main entry point for parsing JavaScript/TypeScript test files. +func Parse(ctx context.Context, source []byte, filename string, framework string) (*domain.TestFile, error) { + lang := DetectLanguage(filename) + + p := parser.NewTSParser(lang) + + tree, err := p.Parse(ctx, source) + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", filename, err) + } + defer tree.Close() + root := tree.RootNode() + + testFile := &domain.TestFile{ + Path: filename, + Language: lang, + Framework: framework, + } + + ParseNode(root, source, filename, testFile, nil) + + return testFile, nil +} diff --git a/src/pkg/parser/strategies/shared/jstest/parser_test.go b/src/pkg/parser/strategies/shared/jstest/parser_test.go new file mode 100644 index 0000000..6db81dc --- /dev/null +++ b/src/pkg/parser/strategies/shared/jstest/parser_test.go @@ -0,0 +1,416 @@ +package jstest + +import ( + "context" + "testing" + + "github.com/specvital/core/domain" +) + +func TestDetectLanguage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filename string + want domain.Language + }{ + { + name: "should detect JavaScript for .js", + filename: "test.js", + want: domain.LanguageJavaScript, + }, + { + name: "should detect JavaScript for .jsx", + filename: "test.jsx", + want: domain.LanguageJavaScript, + }, + { + name: "should detect TypeScript for .ts", + filename: "test.ts", + want: domain.LanguageTypeScript, + }, + { + name: "should detect TypeScript for .tsx", + filename: "test.tsx", + want: domain.LanguageTypeScript, + }, + { + name: "should default to TypeScript for unknown extension", + filename: "test.mjs", + want: domain.LanguageTypeScript, + }, + { + name: "should handle path with directory", + filename: "src/components/Button.test.tsx", + want: domain.LanguageTypeScript, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := DetectLanguage(tt.filename) + + if got != tt.want { + t.Errorf("DetectLanguage(%q) = %q, want %q", tt.filename, got, tt.want) + } + }) + } +} + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + filename string + framework string + wantSuites int + wantTests int + wantLang domain.Language + }{ + { + name: "should parse describe with tests", + source: `describe('Suite', () => { + it('test1', () => {}); + it('test2', () => {}); + });`, + filename: "test.ts", + framework: "jest", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse top-level tests", + source: `it('test1', () => {}); test('test2', () => {});`, + filename: "test.ts", + framework: "vitest", + wantSuites: 0, + wantTests: 2, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse empty file", + source: "", + filename: "test.ts", + framework: "jest", + wantSuites: 0, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse nested describes", + source: `describe('Outer', () => { + describe('Inner', () => { + it('test', () => {}); + }); + });`, + filename: "test.ts", + framework: "jest", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should detect JavaScript language", + source: `describe('Suite', () => { it('test', () => {}); });`, + filename: "test.js", + framework: "jest", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageJavaScript, + }, + { + name: "should use provided framework name", + source: `it('test', () => {});`, + filename: "test.ts", + framework: "custom-framework", + wantSuites: 0, + wantTests: 1, + wantLang: domain.LanguageTypeScript, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + file, err := Parse(context.Background(), []byte(tt.source), tt.filename, tt.framework) + + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if file.Framework != tt.framework { + t.Errorf("Framework = %q, want %q", file.Framework, tt.framework) + } + + if len(file.Suites) != tt.wantSuites { + t.Errorf("len(Suites) = %d, want %d", len(file.Suites), tt.wantSuites) + } + + if len(file.Tests) != tt.wantTests { + t.Errorf("len(Tests) = %d, want %d", len(file.Tests), tt.wantTests) + } + + if file.Language != tt.wantLang { + t.Errorf("Language = %q, want %q", file.Language, tt.wantLang) + } + + if file.Path != tt.filename { + t.Errorf("Path = %q, want %q", file.Path, tt.filename) + } + }) + } +} + +func TestParse_Modifiers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + wantStatus domain.TestStatus + isSuite bool + }{ + { + name: "should parse it.skip", + source: `it.skip('test', () => {});`, + wantStatus: domain.TestStatusSkipped, + isSuite: false, + }, + { + name: "should parse it.only", + source: `it.only('test', () => {});`, + wantStatus: domain.TestStatusOnly, + isSuite: false, + }, + { + name: "should parse test.todo", + source: `test.todo('test');`, + wantStatus: domain.TestStatusSkipped, + isSuite: false, + }, + { + name: "should parse xit", + source: `xit('test', () => {});`, + wantStatus: domain.TestStatusSkipped, + isSuite: false, + }, + { + name: "should parse fit", + source: `fit('test', () => {});`, + wantStatus: domain.TestStatusOnly, + isSuite: false, + }, + { + name: "should parse describe.skip", + source: `describe.skip('Suite', () => {});`, + wantStatus: domain.TestStatusSkipped, + isSuite: true, + }, + { + name: "should parse describe.only", + source: `describe.only('Suite', () => {});`, + wantStatus: domain.TestStatusOnly, + isSuite: true, + }, + { + name: "should parse xdescribe", + source: `xdescribe('Suite', () => {});`, + wantStatus: domain.TestStatusSkipped, + isSuite: true, + }, + { + name: "should parse fdescribe", + source: `fdescribe('Suite', () => {});`, + wantStatus: domain.TestStatusOnly, + isSuite: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + file, err := Parse(context.Background(), []byte(tt.source), "test.ts", "jest") + + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + var status domain.TestStatus + if tt.isSuite { + if len(file.Suites) != 1 { + t.Fatalf("len(Suites) = %d, want 1", len(file.Suites)) + } + status = file.Suites[0].Status + } else { + if len(file.Tests) != 1 { + t.Fatalf("len(Tests) = %d, want 1", len(file.Tests)) + } + status = file.Tests[0].Status + } + + if status != tt.wantStatus { + t.Errorf("Status = %q, want %q", status, tt.wantStatus) + } + }) + } +} + +func TestParse_Each(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + wantCount int + wantFirst string + isSuite bool + }{ + { + name: "should parse describe.each with arrays", + source: `describe.each([['a'], ['b']])('case %s', () => {});`, + wantCount: 2, + wantFirst: "case a", + isSuite: true, + }, + { + name: "should parse it.each with arrays", + source: `it.each([[1], [2], [3]])('test %d', () => {});`, + wantCount: 3, + wantFirst: "test 1", + isSuite: false, + }, + { + name: "should parse test.each with strings", + source: `test.each(['foo', 'bar'])('val %s', () => {});`, + wantCount: 2, + wantFirst: "val foo", + isSuite: false, + }, + { + name: "should handle dynamic cases", + source: `it.each(testData)('test %s', () => {});`, + wantCount: 1, + wantFirst: "test %s (dynamic cases)", + isSuite: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + file, err := Parse(context.Background(), []byte(tt.source), "test.ts", "jest") + + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if tt.isSuite { + if len(file.Suites) != tt.wantCount { + t.Fatalf("len(Suites) = %d, want %d", len(file.Suites), tt.wantCount) + } + if file.Suites[0].Name != tt.wantFirst { + t.Errorf("Suites[0].Name = %q, want %q", file.Suites[0].Name, tt.wantFirst) + } + } else { + if len(file.Tests) != tt.wantCount { + t.Fatalf("len(Tests) = %d, want %d", len(file.Tests), tt.wantCount) + } + if file.Tests[0].Name != tt.wantFirst { + t.Errorf("Tests[0].Name = %q, want %q", file.Tests[0].Name, tt.wantFirst) + } + } + }) + } +} + +func TestParse_Location(t *testing.T) { + t.Parallel() + + source := `describe('Suite', () => { + it('test', () => {}); +});` + + file, err := Parse(context.Background(), []byte(source), "user.test.ts", "jest") + + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if len(file.Suites) != 1 { + t.Fatalf("len(Suites) = %d, want 1", len(file.Suites)) + } + + suite := file.Suites[0] + if suite.Location.File != "user.test.ts" { + t.Errorf("Suite.Location.File = %q, want %q", suite.Location.File, "user.test.ts") + } + if suite.Location.StartLine != 1 { + t.Errorf("Suite.Location.StartLine = %d, want 1", suite.Location.StartLine) + } + + if len(suite.Tests) != 1 { + t.Fatalf("len(suite.Tests) = %d, want 1", len(suite.Tests)) + } + + test := suite.Tests[0] + if test.Location.StartLine != 2 { + t.Errorf("Test.Location.StartLine = %d, want 2", test.Location.StartLine) + } +} + +func TestResolveEachNames(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + template string + testCases []string + want []string + }{ + { + name: "should resolve with test cases", + template: "test %s", + testCases: []string{"a", "b"}, + want: []string{"test a", "test b"}, + }, + { + name: "should add dynamic suffix when empty", + template: "test %s", + testCases: []string{}, + want: []string{"test %s (dynamic cases)"}, + }, + { + name: "should handle nil", + template: "test %s", + testCases: nil, + want: []string{"test %s (dynamic cases)"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ResolveEachNames(tt.template, tt.testCases) + + if len(got) != len(tt.want) { + t.Fatalf("len(result) = %d, want %d", len(got), len(tt.want)) + } + + for i, want := range tt.want { + if got[i] != want { + t.Errorf("result[%d] = %q, want %q", i, got[i], want) + } + } + }) + } +} diff --git a/src/pkg/parser/strategies/vitest/vitest.go b/src/pkg/parser/strategies/vitest/vitest.go new file mode 100644 index 0000000..bf2a326 --- /dev/null +++ b/src/pkg/parser/strategies/vitest/vitest.go @@ -0,0 +1,60 @@ +package vitest + +import ( + "context" + "regexp" + + "github.com/specvital/core/domain" + "github.com/specvital/core/parser/strategies" + "github.com/specvital/core/parser/strategies/shared/jstest" +) + +const ( + frameworkName = "vitest" + // priorityOffset is added to DefaultPriority to ensure Vitest takes precedence + // over Jest when a file contains vitest imports, since both frameworks share + // similar test syntax. + priorityOffset = 10 +) + +// vitestImportPattern matches import/require statements for 'vitest'. +// Matches: import ... from 'vitest', import ... from "vitest", require('vitest'), require("vitest") +var vitestImportPattern = regexp.MustCompile(`(?:import\s+.*\s+from|require\()\s*['"]vitest['"]`) + +type Strategy struct{} + +func NewStrategy() *Strategy { + return &Strategy{} +} + +func RegisterDefault() { + strategies.Register(NewStrategy()) +} + +func (s *Strategy) Name() string { + return frameworkName +} + +func (s *Strategy) Priority() int { + return strategies.DefaultPriority + priorityOffset +} + +func (s *Strategy) Languages() []domain.Language { + return []domain.Language{domain.LanguageTypeScript, domain.LanguageJavaScript} +} + +func (s *Strategy) CanHandle(filename string, content []byte) bool { + if !jstest.IsTestFile(filename) { + return false + } + + return hasVitestImport(content) +} + +func hasVitestImport(content []byte) bool { + return vitestImportPattern.Match(content) +} + +func (s *Strategy) Parse(ctx context.Context, source []byte, filename string) (*domain.TestFile, error) { + return jstest.Parse(ctx, source, filename, frameworkName) +} diff --git a/src/pkg/parser/strategies/vitest/vitest_test.go b/src/pkg/parser/strategies/vitest/vitest_test.go new file mode 100644 index 0000000..a0b53aa --- /dev/null +++ b/src/pkg/parser/strategies/vitest/vitest_test.go @@ -0,0 +1,410 @@ +package vitest + +import ( + "context" + "testing" + + "github.com/specvital/core/domain" + "github.com/specvital/core/parser/strategies" +) + +func TestNewStrategy(t *testing.T) { + t.Parallel() + + // When + s := NewStrategy() + + // Then + if s == nil { + t.Fatal("NewStrategy() returned nil") + } +} + +func TestStrategy_Name(t *testing.T) { + t.Parallel() + + // Given + s := NewStrategy() + + // When + name := s.Name() + + // Then + if name != "vitest" { + t.Errorf("Name() = %q, want %q", name, "vitest") + } +} + +func TestStrategy_Priority(t *testing.T) { + t.Parallel() + + // Given + s := NewStrategy() + + // When + priority := s.Priority() + + // Then + expectedPriority := strategies.DefaultPriority + 10 + if priority != expectedPriority { + t.Errorf("Priority() = %d, want %d", priority, expectedPriority) + } +} + +func TestStrategy_Languages(t *testing.T) { + t.Parallel() + + // Given + s := NewStrategy() + + // When + langs := s.Languages() + + // Then + if len(langs) != 2 { + t.Fatalf("len(Languages()) = %d, want 2", len(langs)) + } + if langs[0] != domain.LanguageTypeScript { + t.Errorf("Languages()[0] = %q, want %q", langs[0], domain.LanguageTypeScript) + } + if langs[1] != domain.LanguageJavaScript { + t.Errorf("Languages()[1] = %q, want %q", langs[1], domain.LanguageJavaScript) + } +} + +func TestStrategy_CanHandle(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + filename string + content string + want bool + }{ + { + name: "should handle .test.ts with vitest import", + filename: "user.test.ts", + content: `import { describe, it } from 'vitest';`, + want: true, + }, + { + name: "should handle .spec.ts with vitest import", + filename: "user.spec.ts", + content: `import { expect } from 'vitest';`, + want: true, + }, + { + name: "should handle __tests__ directory with vitest import", + filename: "__tests__/user.ts", + content: `import { vi } from 'vitest';`, + want: true, + }, + { + name: "should reject test file without vitest import", + filename: "user.test.ts", + content: `import { describe, it } from '@jest/globals';`, + want: false, + }, + { + name: "should reject non-test file even with vitest import", + filename: "user.ts", + content: `import { describe } from 'vitest';`, + want: false, + }, + { + name: "should handle vitest in require statement", + filename: "user.test.js", + content: `const { describe } = require('vitest');`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := s.CanHandle(tt.filename, []byte(tt.content)) + + // Then + if got != tt.want { + t.Errorf("CanHandle(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestStrategy_Parse(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + filename string + wantSuites int + wantTests int + wantLang domain.Language + }{ + { + name: "should parse simple describe", + source: `import { describe, it } from 'vitest'; +describe('Suite', () => { + it('test1', () => {}); + it('test2', () => {}); +});`, + filename: "user.test.ts", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse nested describe", + source: `import { describe, it } from 'vitest'; +describe('Outer', () => { + describe('Inner', () => { + it('test', () => {}); + }); +});`, + filename: "user.test.ts", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse top-level tests", + source: `import { it, test } from 'vitest'; it('test1', () => {}); test('test2', () => {});`, + filename: "user.test.ts", + wantSuites: 0, + wantTests: 2, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should detect JavaScript", + source: `import { describe, it } from 'vitest'; describe('JS', () => { it('test', () => {}); });`, + filename: "user.test.js", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageJavaScript, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + file, err := s.Parse(context.Background(), []byte(tt.source), tt.filename) + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if file.Framework != "vitest" { + t.Errorf("Framework = %q, want %q", file.Framework, "vitest") + } + if len(file.Suites) != tt.wantSuites { + t.Errorf("len(Suites) = %d, want %d", len(file.Suites), tt.wantSuites) + } + if len(file.Tests) != tt.wantTests { + t.Errorf("len(Tests) = %d, want %d", len(file.Tests), tt.wantTests) + } + if file.Language != tt.wantLang { + t.Errorf("Language = %q, want %q", file.Language, tt.wantLang) + } + }) + } +} + +func TestStrategy_Parse_Modifiers(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + wantName string + wantStatus domain.TestStatus + isTest bool + }{ + { + name: "should parse it.skip", + source: `describe('S', () => { it.skip('test', () => {}); });`, + wantName: "test", + wantStatus: domain.TestStatusSkipped, + isTest: true, + }, + { + name: "should parse it.only", + source: `describe('S', () => { it.only('test', () => {}); });`, + wantName: "test", + wantStatus: domain.TestStatusOnly, + isTest: true, + }, + { + name: "should parse test.todo", + source: `describe('S', () => { test.todo('test'); });`, + wantName: "test", + wantStatus: domain.TestStatusSkipped, + isTest: true, + }, + { + name: "should parse describe.skip", + source: `describe.skip('Suite', () => {});`, + wantName: "Suite", + wantStatus: domain.TestStatusSkipped, + isTest: false, + }, + { + name: "should parse describe.only", + source: `describe.only('Suite', () => {});`, + wantName: "Suite", + wantStatus: domain.TestStatusOnly, + isTest: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + file, err := s.Parse(context.Background(), []byte(tt.source), "test.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if tt.isTest { + if len(file.Suites) != 1 || len(file.Suites[0].Tests) != 1 { + t.Fatal("Expected 1 suite with 1 test") + } + test := file.Suites[0].Tests[0] + if test.Name != tt.wantName { + t.Errorf("Test.Name = %q, want %q", test.Name, tt.wantName) + } + if test.Status != tt.wantStatus { + t.Errorf("Test.Status = %q, want %q", test.Status, tt.wantStatus) + } + } else { + if len(file.Suites) != 1 { + t.Fatal("Expected 1 suite") + } + suite := file.Suites[0] + if suite.Name != tt.wantName { + t.Errorf("Suite.Name = %q, want %q", suite.Name, tt.wantName) + } + if suite.Status != tt.wantStatus { + t.Errorf("Suite.Status = %q, want %q", suite.Status, tt.wantStatus) + } + } + }) + } +} + +func TestStrategy_Parse_Each(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + wantCount int + wantFirst string + isSuite bool + }{ + { + name: "should parse describe.each", + source: `describe.each([['a'], ['b']])('case %s', () => {});`, + wantCount: 2, + wantFirst: "case a", + isSuite: true, + }, + { + name: "should parse it.each", + source: `describe('S', () => { it.each([[1], [2], [3]])('test %d', () => {}); });`, + wantCount: 3, + wantFirst: "test 1", + isSuite: false, + }, + { + name: "should parse test.each", + source: `describe('S', () => { test.each([['x']])('val %s', () => {}); });`, + wantCount: 1, + wantFirst: "val x", + isSuite: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + file, err := s.Parse(context.Background(), []byte(tt.source), "test.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if tt.isSuite { + if len(file.Suites) != tt.wantCount { + t.Fatalf("len(Suites) = %d, want %d", len(file.Suites), tt.wantCount) + } + if file.Suites[0].Name != tt.wantFirst { + t.Errorf("Suites[0].Name = %q, want %q", file.Suites[0].Name, tt.wantFirst) + } + } else { + if len(file.Suites) != 1 { + t.Fatal("Expected 1 suite") + } + if len(file.Suites[0].Tests) != tt.wantCount { + t.Fatalf("len(Tests) = %d, want %d", len(file.Suites[0].Tests), tt.wantCount) + } + if file.Suites[0].Tests[0].Name != tt.wantFirst { + t.Errorf("Tests[0].Name = %q, want %q", file.Suites[0].Tests[0].Name, tt.wantFirst) + } + } + }) + } +} + +func TestRegisterDefault(t *testing.T) { + strategies.DefaultRegistry().Clear() + defer strategies.DefaultRegistry().Clear() + + // When + RegisterDefault() + + // Then + all := strategies.GetStrategies() + if len(all) != 1 { + t.Fatalf("len(strategies) = %d, want 1", len(all)) + } + if all[0].Name() != "vitest" { + t.Errorf("Name = %q, want %q", all[0].Name(), "vitest") + } +} + +func TestVitestHigherPriorityThanJest(t *testing.T) { + t.Parallel() + + // Given + vitestStrategy := NewStrategy() + jestPriority := strategies.DefaultPriority + + // When + vitestPriority := vitestStrategy.Priority() + + // Then + if vitestPriority <= jestPriority { + t.Errorf("Vitest priority (%d) should be higher than Jest priority (%d)", vitestPriority, jestPriority) + } +}