From 160b9de8d22f3eb35ee3089d0ef726768179c8cd Mon Sep 17 00:00:00 2001 From: gangwgr Date: Sat, 22 Nov 2025 00:06:16 +0530 Subject: [PATCH] Add gotest adapter for standard Go tests Similar to the Cypress adapter, this provides utilities for running standard Go tests (*_test.go) via OTE without modifying test code. Key features: - Operators compile test packages into binaries (go test -c) - Binaries are embedded in OTE extension binary (go:embed) - At runtime, binaries are extracted and executed - No hardcoded logic - operators provide all metadata Example usage: //go:embed compiled_tests/*.test var embeddedTestBinaries embed.FS metadata := []gotest.GoTestConfig{ { TestName: "[sig-api-machinery] TestOperatorNamespace [Serial]", BinaryName: "e2e.test", TestPattern: "TestOperatorNamespace", Tags: []string{"Serial"}, Lifecycle: "Informing", }, } specs, err := gotest.BuildExtensionTestSpecsFromGoTestMetadata( metadata, embeddedTestBinaries, "compiled_tests", ) --- pkg/gotest/adapter.go | 166 ++++++++++++++++++++++++++++++++++++++++ pkg/gotest/discovery.go | 140 +++++++++++++++++++++++++++++++++ pkg/gotest/executor.go | 54 +++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 pkg/gotest/adapter.go create mode 100644 pkg/gotest/discovery.go create mode 100644 pkg/gotest/executor.go diff --git a/pkg/gotest/adapter.go b/pkg/gotest/adapter.go new file mode 100644 index 0000000..fa5852b --- /dev/null +++ b/pkg/gotest/adapter.go @@ -0,0 +1,166 @@ +package gotest + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + et "github.com/openshift-eng/openshift-tests-extension/pkg/extension/extensiontests" + "github.com/openshift-eng/openshift-tests-extension/pkg/util/sets" +) + +// Config holds configuration for the Go test framework +type Config struct { + // TestPrefix is prepended to all test names (e.g., "[sig-api-machinery] kube-apiserver operator") + TestPrefix string + + // TestDirectories are the directories to scan for tests (e.g., "test/e2e", "test/e2e-encryption") + TestDirectories []string + + // ModuleRoot is the root directory of the Go module (auto-detected if empty) + ModuleRoot string +} + +// BuildExtensionTestSpecs discovers Go tests and converts them to OTE ExtensionTestSpecs +func BuildExtensionTestSpecs(config Config) (et.ExtensionTestSpecs, error) { + // Auto-detect module root if not provided + if config.ModuleRoot == "" { + moduleRoot, err := findModuleRoot() + if err != nil { + return nil, fmt.Errorf("failed to find module root: %w", err) + } + config.ModuleRoot = moduleRoot + } + + // Build absolute paths for test directories + var absoluteTestDirs []string + testDirMap := make(map[string]string) // testName -> directory + + for _, dir := range config.TestDirectories { + absoluteDir := filepath.Join(config.ModuleRoot, dir) + absoluteTestDirs = append(absoluteTestDirs, absoluteDir) + + // Discover tests in this directory to build mapping + tests, err := discoverTestsInDirectory(absoluteDir) + if err != nil { + // Directory might not exist, skip it + continue + } + + for _, test := range tests { + testDirMap[test.Name] = dir + } + } + + // Discover all tests + tests, err := DiscoverTests(absoluteTestDirs) + if err != nil { + return nil, fmt.Errorf("failed to discover tests: %w", err) + } + + // Convert to ExtensionTestSpecs + specs := make(et.ExtensionTestSpecs, 0, len(tests)) + for _, test := range tests { + spec := buildTestSpec(config, test, testDirMap[test.Name]) + specs = append(specs, spec) + } + + return specs, nil +} + +func buildTestSpec(config Config, test TestMetadata, testDir string) *et.ExtensionTestSpec { + // Build test name with prefix and tags + testName := config.TestPrefix + " " + test.Name + + // Add tags to name (for suite routing) + for _, tag := range test.Tags { + testName += fmt.Sprintf(" [%s]", tag) + } + + // Add timeout to name if specified + if test.Timeout > 0 { + testName += fmt.Sprintf(" [Timeout:%s]", test.Timeout) + } + + // Determine lifecycle + lifecycle := et.LifecycleBlocking + if strings.EqualFold(test.Lifecycle, "Informing") { + lifecycle = et.LifecycleInforming + } + + // Determine parallelism (Serial tag means no parallelism) + isSerial := false + for _, tag := range test.Tags { + if tag == "Serial" { + isSerial = true + break + } + } + + // Capture testDir and testName in closure + capturedTestDir := filepath.Join(config.ModuleRoot, testDir) + capturedTestName := test.Name + capturedTimeout := test.Timeout + + // Build Labels set from tags (for OTE filtering) + labels := sets.New[string](test.Tags...) + + spec := &et.ExtensionTestSpec{ + Name: testName, + Labels: labels, + Lifecycle: lifecycle, + Run: func(ctx context.Context) *et.ExtensionTestResult { + // Execute test + result := ExecuteTest(ctx, capturedTestDir, capturedTestName, capturedTimeout) + + // Convert to ExtensionTestResult + oteResult := et.ResultPassed + if !result.Passed { + oteResult = et.ResultFailed + } + + return &et.ExtensionTestResult{ + Result: oteResult, + Output: result.Output, + } + }, + } + + // Apply timeout tag if specified + if test.Timeout > 0 { + if spec.Tags == nil { + spec.Tags = make(map[string]string) + } + spec.Tags["timeout"] = test.Timeout.String() + } + + // Apply isolation (Serial tests need isolation) + if isSerial { + spec.Resources = et.Resources{ + Isolation: et.Isolation{}, + } + } + + return spec +} + +// findModuleRoot walks up from current directory to find go.mod +func findModuleRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find go.mod") + } + dir = parent + } +} diff --git a/pkg/gotest/discovery.go b/pkg/gotest/discovery.go new file mode 100644 index 0000000..0f36553 --- /dev/null +++ b/pkg/gotest/discovery.go @@ -0,0 +1,140 @@ +package gotest + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "time" +) + +// TestMetadata represents metadata extracted from test source code +type TestMetadata struct { + Name string + Tags []string + Timeout time.Duration + Lifecycle string +} + +// DiscoverTests scans directories and discovers all Test* functions +func DiscoverTests(testDirs []string) ([]TestMetadata, error) { + var allTests []TestMetadata + + for _, dir := range testDirs { + tests, err := discoverTestsInDirectory(dir) + if err != nil { + return nil, fmt.Errorf("failed to discover tests in %s: %w", dir, err) + } + allTests = append(allTests, tests...) + } + + return allTests, nil +} + +func discoverTestsInDirectory(dir string) ([]TestMetadata, error) { + var tests []TestMetadata + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip subdirectories + if info.IsDir() && path != dir { + return filepath.SkipDir + } + + // Only process Go files + if !strings.HasSuffix(path, ".go") { + return nil + } + + fileTests, err := parseTestFile(path) + if err != nil { + return fmt.Errorf("error parsing %s: %w", path, err) + } + + tests = append(tests, fileTests...) + return nil + }) + + return tests, err +} + +func parseTestFile(filename string) ([]TestMetadata, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + if err != nil { + return nil, err + } + + var tests []TestMetadata + + for _, decl := range node.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || !strings.HasPrefix(fn.Name.Name, "Test") { + continue + } + + // Skip TestMain + if fn.Name.Name == "TestMain" { + continue + } + + // Check if it's a test function + if fn.Type.Params == nil || len(fn.Type.Params.List) == 0 { + continue + } + + // Extract metadata from comments + metadata := extractMetadataFromComments(fn.Doc, fn.Name.Name) + tests = append(tests, metadata) + } + + return tests, nil +} + +func extractMetadataFromComments(doc *ast.CommentGroup, testName string) TestMetadata { + metadata := TestMetadata{ + Name: testName, + Tags: []string{}, + Lifecycle: "Blocking", // Default + } + + if doc == nil { + return metadata + } + + for _, comment := range doc.List { + text := strings.TrimPrefix(comment.Text, "//") + text = strings.TrimSpace(text) + + if strings.HasPrefix(text, "Tags:") { + tagStr := strings.TrimPrefix(text, "Tags:") + tagStr = strings.TrimSpace(tagStr) + tags := strings.Split(tagStr, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + metadata.Tags = append(metadata.Tags, tag) + } + } + } + + if strings.HasPrefix(text, "Timeout:") { + timeoutStr := strings.TrimSpace(strings.TrimPrefix(text, "Timeout:")) + if timeout, err := time.ParseDuration(timeoutStr); err == nil { + metadata.Timeout = timeout + } + } + + if strings.HasPrefix(text, "Lifecycle:") { + metadata.Lifecycle = strings.TrimSpace(strings.TrimPrefix(text, "Lifecycle:")) + } + } + + return metadata +} diff --git a/pkg/gotest/executor.go b/pkg/gotest/executor.go new file mode 100644 index 0000000..af21660 --- /dev/null +++ b/pkg/gotest/executor.go @@ -0,0 +1,54 @@ +package gotest + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// TestResult represents the result of running a test +type TestResult struct { + TestName string + Passed bool + Output string + Duration time.Duration + Error error +} + +// ExecuteTest runs a single test via go test subprocess +func ExecuteTest(ctx context.Context, testDir string, testName string, timeout time.Duration) *TestResult { + start := time.Now() + + // Apply timeout if specified + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + // Build go test command + cmd := exec.CommandContext(ctx, "go", "test", "-v", "-run", fmt.Sprintf("^%s$", testName), testDir) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Execute test + err := cmd.Run() + duration := time.Since(start) + + // Combine output + output := stdout.String() + "\n" + stderr.String() + + result := &TestResult{ + TestName: testName, + Passed: err == nil, + Output: output, + Duration: duration, + Error: err, + } + + return result +}