From c779d7063fdc58e60b085e9daf21b2a8453db7b0 Mon Sep 17 00:00:00 2001 From: kubrickcode Date: Wed, 3 Dec 2025 15:23:00 +0000 Subject: [PATCH] feat: add Playwright test parser support Add parsing support for Playwright E2E test framework - Detect files by `@playwright/test` import - Parse `test`, `test.describe` constructs - Handle `test.skip`, `test.only`, `test.fixme` status - Support `test.describe.skip`, `test.describe.only`, `test.describe.fixme` fix #6 --- .../strategies/playwright/playwright.go | 261 +++++++++ .../strategies/playwright/playwright_test.go | 521 ++++++++++++++++++ 2 files changed, 782 insertions(+) create mode 100644 src/pkg/parser/strategies/playwright/playwright.go create mode 100644 src/pkg/parser/strategies/playwright/playwright_test.go diff --git a/src/pkg/parser/strategies/playwright/playwright.go b/src/pkg/parser/strategies/playwright/playwright.go new file mode 100644 index 0000000..db22eb5 --- /dev/null +++ b/src/pkg/parser/strategies/playwright/playwright.go @@ -0,0 +1,261 @@ +package playwright + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + + sitter "github.com/smacker/go-tree-sitter" + + "github.com/specvital/core/domain" + "github.com/specvital/core/parser" + "github.com/specvital/core/parser/strategies" + "github.com/specvital/core/parser/strategies/shared/jstest" +) + +const ( + frameworkName = "playwright" + + // Function names for Playwright test API + funcTest = "test" + funcTestDescribe = "test.describe" + + // Playwright-specific modifier + modifierFixme = "fixme" +) + +// playwrightImportPattern matches import/require statements for '@playwright/test'. +// Limitations: +// - Does not match dynamic imports: import('@playwright/test') +// - Does not match re-exports: export { test } from '@playwright/test' +var playwrightImportPattern = regexp.MustCompile(`(?:import\s+.*\s+from|require\()\s*['"]@playwright/test['"]`) + +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 +} + +func (s *Strategy) Languages() []domain.Language { + return []domain.Language{domain.LanguageTypeScript, domain.LanguageJavaScript} +} + +func (s *Strategy) CanHandle(filename string, content []byte) bool { + // Playwright E2E tests: only .ts and .js files + ext := filepath.Ext(filename) + if ext != ".ts" && ext != ".js" { + return false + } + + if !jstest.IsTestFile(filename) { + return false + } + + return hasPlaywrightImport(content) +} + +func (s *Strategy) Parse(ctx context.Context, source []byte, filename string) (*domain.TestFile, error) { + lang := jstest.DetectLanguage(filename) + p := parser.NewTSParser(lang) + + tree, err := p.Parse(ctx, source) + if err != nil { + return nil, fmt.Errorf("playwright parser: failed to parse %s: %w", filename, err) + } + defer tree.Close() + root := tree.RootNode() + + testFile := &domain.TestFile{ + Path: filename, + Language: lang, + Framework: frameworkName, + } + + parseNode(root, source, filename, testFile, nil) + + return testFile, nil +} + +// Helper functions (alphabetically ordered) + +func hasPlaywrightImport(content []byte) bool { + return playwrightImportPattern.Match(content) +} + +func parseFunctionName(node *sitter.Node, source []byte) (string, domain.TestStatus) { + switch node.Type() { + case "identifier": + name := parser.GetNodeText(node, source) + if name == funcTest { + return funcTest, domain.TestStatusPending + } + return "", domain.TestStatusPending + case "member_expression": + return parseMemberExpression(node, source) + default: + return "", domain.TestStatusPending + } +} + +func parseMemberExpression(node *sitter.Node, source []byte) (string, domain.TestStatus) { + obj := node.ChildByFieldName("object") + prop := node.ChildByFieldName("property") + + if obj == nil || prop == nil { + return "", domain.TestStatusPending + } + + switch obj.Type() { + case "identifier": + return parseSimpleMemberExpression(obj, prop, source) + case "member_expression": + return parseNestedMemberExpression(obj, prop, source) + } + + return "", domain.TestStatusPending +} + +func parseModifierStatus(modifier string) domain.TestStatus { + switch modifier { + case jstest.ModifierSkip: + return domain.TestStatusSkipped + case jstest.ModifierOnly: + return domain.TestStatusOnly + case modifierFixme: + return domain.TestStatusFixme + default: + return domain.TestStatusPending + } +} + +func parseNestedMemberExpression(obj *sitter.Node, prop *sitter.Node, source []byte) (string, domain.TestStatus) { + innerObj := obj.ChildByFieldName("object") + innerProp := obj.ChildByFieldName("property") + + if innerObj == nil || innerProp == nil { + return "", domain.TestStatusPending + } + + objName := parser.GetNodeText(innerObj, source) + if objName != funcTest { + return "", domain.TestStatusPending + } + + middleProp := parser.GetNodeText(innerProp, source) + if middleProp == "describe" { + outerProp := parser.GetNodeText(prop, source) + return funcTestDescribe, parseModifierStatus(outerProp) + } + + return "", domain.TestStatusPending +} + +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) + } + } +} + +func parseSimpleMemberExpression(obj *sitter.Node, prop *sitter.Node, source []byte) (string, domain.TestStatus) { + objName := parser.GetNodeText(obj, source) + if objName != funcTest { + return "", domain.TestStatusPending + } + + propName := parser.GetNodeText(prop, source) + switch propName { + case "describe": + return funcTestDescribe, domain.TestStatusPending + case jstest.ModifierSkip: + return funcTest, domain.TestStatusSkipped + case jstest.ModifierOnly: + return funcTest, domain.TestStatusOnly + case modifierFixme: + return funcTest, domain.TestStatusFixme + } + + return "", domain.TestStatusPending +} + +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 + } + + funcName, status := parseFunctionName(funcNode, source) + if funcName == "" { + return + } + + switch funcName { + case funcTestDescribe: + processSuite(node, args, source, filename, file, currentSuite, status) + case funcTest: + processTest(node, args, source, filename, file, currentSuite, status) + } +} + +func processSuite(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + name := jstest.ExtractTestName(args, source) + if name == "" { + return + } + + suite := domain.TestSuite{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + if callback := jstest.FindCallback(args); callback != nil { + body := callback.ChildByFieldName("body") + if body != nil { + parseNode(body, source, filename, file, &suite) + } + } + + jstest.AddSuiteToTarget(suite, parentSuite, file) +} + +func processTest(callNode *sitter.Node, args *sitter.Node, source []byte, filename string, file *domain.TestFile, parentSuite *domain.TestSuite, status domain.TestStatus) { + name := jstest.ExtractTestName(args, source) + if name == "" { + return + } + + test := domain.Test{ + Name: name, + Status: status, + Location: parser.GetLocation(callNode, filename), + } + + jstest.AddTestToTarget(test, parentSuite, file) +} diff --git a/src/pkg/parser/strategies/playwright/playwright_test.go b/src/pkg/parser/strategies/playwright/playwright_test.go new file mode 100644 index 0000000..8cc0899 --- /dev/null +++ b/src/pkg/parser/strategies/playwright/playwright_test.go @@ -0,0 +1,521 @@ +package playwright + +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 != "playwright" { + t.Errorf("Name() = %q, want %q", name, "playwright") + } +} + +func TestStrategy_Priority(t *testing.T) { + t.Parallel() + + // Given + s := NewStrategy() + + // When + priority := s.Priority() + + // Then + if priority != strategies.DefaultPriority { + t.Errorf("Priority() = %d, want %d", priority, strategies.DefaultPriority) + } +} + +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 playwright import", + filename: "user.test.ts", + content: `import { test, expect } from '@playwright/test';`, + want: true, + }, + { + name: "should handle .spec.ts with playwright import", + filename: "user.spec.ts", + content: `import { test } from '@playwright/test';`, + want: true, + }, + { + name: "should handle __tests__ directory with playwright import", + filename: "__tests__/user.ts", + content: `import { expect } from '@playwright/test';`, + want: true, + }, + { + name: "should reject test file without playwright import", + filename: "user.test.ts", + content: `import { test } from 'vitest';`, + want: false, + }, + { + name: "should reject non-test file even with playwright import", + filename: "user.ts", + content: `import { test } from '@playwright/test';`, + want: false, + }, + { + name: "should handle playwright in require statement", + filename: "user.test.js", + content: `const { test } = require('@playwright/test');`, + want: true, + }, + { + name: "should reject tsx files", + filename: "user.test.tsx", + content: `import { test } from '@playwright/test';`, + want: false, + }, + } + + 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_BasicStructure(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 test.describe with tests", + source: `import { test, expect } from '@playwright/test'; +test.describe('Login', () => { + test('should display login form', async ({ page }) => {}); + test('should handle valid credentials', async ({ page }) => {}); +});`, + filename: "login.spec.ts", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse nested test.describe", + source: `import { test } from '@playwright/test'; +test.describe('Auth', () => { + test.describe('Login', () => { + test('works', async () => {}); + }); +});`, + filename: "auth.spec.ts", + wantSuites: 1, + wantTests: 0, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should parse top-level tests", + source: `import { test } from '@playwright/test'; test('test1', async () => {}); test('test2', async () => {});`, + filename: "basic.spec.ts", + wantSuites: 0, + wantTests: 2, + wantLang: domain.LanguageTypeScript, + }, + { + name: "should detect JavaScript", + source: `import { test } from '@playwright/test'; test.describe('JS', () => { test('works', async () => {}); });`, + filename: "basic.spec.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 != "playwright" { + t.Errorf("Framework = %q, want %q", file.Framework, "playwright") + } + 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_TestModifiers(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + wantName string + wantStatus domain.TestStatus + }{ + { + name: "should parse test.skip", + source: `test.describe('S', () => { test.skip('skipped test', async () => {}); });`, + wantName: "skipped test", + wantStatus: domain.TestStatusSkipped, + }, + { + name: "should parse test.only", + source: `test.describe('S', () => { test.only('focused test', async () => {}); });`, + wantName: "focused test", + wantStatus: domain.TestStatusOnly, + }, + { + name: "should parse test.fixme", + source: `test.describe('S', () => { test.fixme('broken test', async () => {}); });`, + wantName: "broken test", + wantStatus: domain.TestStatusFixme, + }, + { + name: "should parse regular test as pending", + source: `test.describe('S', () => { test('normal test', async () => {}); });`, + wantName: "normal test", + wantStatus: domain.TestStatusPending, + }, + } + + 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.spec.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + 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) + } + }) + } +} + +func TestStrategy_Parse_DescribeModifiers(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + wantName string + wantStatus domain.TestStatus + }{ + { + name: "should parse test.describe.skip", + source: `test.describe.skip('Skipped Suite', () => {});`, + wantName: "Skipped Suite", + wantStatus: domain.TestStatusSkipped, + }, + { + name: "should parse test.describe.only", + source: `test.describe.only('Focused Suite', () => {});`, + wantName: "Focused Suite", + wantStatus: domain.TestStatusOnly, + }, + { + name: "should parse test.describe.fixme", + source: `test.describe.fixme('Broken Suite', () => {});`, + wantName: "Broken Suite", + wantStatus: domain.TestStatusFixme, + }, + { + name: "should parse regular test.describe as pending", + source: `test.describe('Normal Suite', () => {});`, + wantName: "Normal Suite", + wantStatus: domain.TestStatusPending, + }, + } + + 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.spec.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + 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_NestedStructure(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + source := `import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.describe('Login', () => { + test('should display login form', async ({ page }) => {}); + test('should handle invalid credentials', async ({ page }) => {}); + }); + + test.describe('Logout', () => { + test('should clear session', async ({ page }) => {}); + }); +});` + + // When + file, err := s.Parse(context.Background(), []byte(source), "auth.spec.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Root level: 1 suite (Authentication) + if len(file.Suites) != 1 { + t.Fatalf("len(file.Suites) = %d, want 1", len(file.Suites)) + } + + authSuite := file.Suites[0] + if authSuite.Name != "Authentication" { + t.Errorf("authSuite.Name = %q, want %q", authSuite.Name, "Authentication") + } + + // Authentication has 2 nested suites + if len(authSuite.Suites) != 2 { + t.Fatalf("len(authSuite.Suites) = %d, want 2", len(authSuite.Suites)) + } + + // Login suite has 2 tests + loginSuite := authSuite.Suites[0] + if loginSuite.Name != "Login" { + t.Errorf("loginSuite.Name = %q, want %q", loginSuite.Name, "Login") + } + if len(loginSuite.Tests) != 2 { + t.Errorf("len(loginSuite.Tests) = %d, want 2", len(loginSuite.Tests)) + } + + // Logout suite has 1 test + logoutSuite := authSuite.Suites[1] + if logoutSuite.Name != "Logout" { + t.Errorf("logoutSuite.Name = %q, want %q", logoutSuite.Name, "Logout") + } + if len(logoutSuite.Tests) != 1 { + t.Errorf("len(logoutSuite.Tests) = %d, want 1", len(logoutSuite.Tests)) + } +} + +func TestStrategy_Parse_Location(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + source := `import { test } from '@playwright/test'; + +test('first test', async () => {});` + + // When + file, err := s.Parse(context.Background(), []byte(source), "test.spec.ts") + + // Then + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(file.Tests) != 1 { + t.Fatal("Expected 1 test") + } + + test := file.Tests[0] + if test.Location.File != "test.spec.ts" { + t.Errorf("Location.File = %q, want %q", test.Location.File, "test.spec.ts") + } + if test.Location.StartLine != 3 { + t.Errorf("Location.StartLine = %d, want %d", test.Location.StartLine, 3) + } +} + +func TestRegisterDefault(t *testing.T) { + // Not parallel - modifies global registry state + 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() != "playwright" { + t.Errorf("Name = %q, want %q", all[0].Name(), "playwright") + } +} + +// mustParse is a test helper that parses source and fails if error occurs. +func mustParse(t *testing.T, s *Strategy, source, filename string) *domain.TestFile { + t.Helper() + file, err := s.Parse(context.Background(), []byte(source), filename) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + return file +} + +func TestStrategy_Parse_EdgeCases(t *testing.T) { + t.Parallel() + + s := &Strategy{} + + tests := []struct { + name string + source string + wantSuites int + wantTests int + }{ + { + name: "should handle empty file", + source: ``, + wantSuites: 0, + wantTests: 0, + }, + { + name: "should handle file with only imports", + source: `import { test } from '@playwright/test';`, + wantSuites: 0, + wantTests: 0, + }, + { + name: "should ignore test calls without name", + source: `test.describe('S', () => { test(); });`, + wantSuites: 1, + wantTests: 0, + }, + { + name: "should handle describe without callback as empty suite", + source: `test.describe('Empty');`, + wantSuites: 1, + wantTests: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + file := mustParse(t, s, tt.source, "test.spec.ts") + + // Then + 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) + } + }) + } +}