diff --git a/cmd/dmt/root.go b/cmd/dmt/root.go index 664c03da..f7639e8e 100644 --- a/cmd/dmt/root.go +++ b/cmd/dmt/root.go @@ -32,6 +32,8 @@ import ( "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/internal/fsutils" "github.com/deckhouse/dmt/internal/logger" + "github.com/deckhouse/dmt/internal/test" + "github.com/deckhouse/dmt/internal/test/conversions" "github.com/deckhouse/dmt/internal/version" ) @@ -137,11 +139,31 @@ func execute() { }, } + testCmd := &cobra.Command{ + Use: "test ", + Short: "run tests for Deckhouse modules", + Long: `Run various tests for Deckhouse modules. + +Currently supported test types: + - conversions: tests for config version conversions + +To run conversion tests, create openapi/conversions/conversions_test.yaml in your module. + +Example: + dmt test ./my-module + dmt test /path/to/module --type conversions`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: testCmdFunc, + } + lintCmd.Flags().AddFlagSet(flags.InitLintFlagSet()) bootstrapCmd.Flags().AddFlagSet(flags.InitBootstrapFlagSet()) + testCmd.Flags().AddFlagSet(flags.InitTestFlagSet()) rootCmd.AddCommand(lintCmd) rootCmd.AddCommand(bootstrapCmd) + rootCmd.AddCommand(testCmd) rootCmd.Flags().AddFlagSet(flags.InitDefaultFlagSet()) err := rootCmd.Execute() @@ -190,3 +212,48 @@ func runLintMultiple(dirs []string) error { return nil } + +func testCmdFunc(_ *cobra.Command, args []string) error { + modulePath, err := fsutils.ExpandDir(args[0]) + if err != nil { + return fmt.Errorf("error expanding path %s: %w", args[0], err) + } + + return runTests(modulePath) +} + +func runTests(dir string) error { + logger.InfoF("Running tests for: %s", dir) + + // Create test runner and register testers + runner := test.NewRunner() + runner.Register(conversions.NewTester()) + + // Build test options + opts := test.RunOptions{ + ModulePath: dir, + Verbose: flags.TestVerbose, + } + + // Filter by test type if specified + if flags.TestType != "" { + opts.TestTypes = []test.TestType{test.TestType(flags.TestType)} + } + + // Run tests + summary, err := runner.Run(opts) + if err != nil { + logger.ErrorF("Test execution failed: %v", err) + return err + } + + // Print results + test.PrintSummary(summary) + + // Return error if any tests failed + if summary.FailedTests > 0 { + return errors.New("some tests failed") + } + + return nil +} diff --git a/go.mod b/go.mod index b9554c23..75994545 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang/snappy v0.0.4 github.com/google/go-containerregistry v0.20.2 github.com/iancoleman/strcase v0.3.0 + github.com/itchyny/gojq v0.12.18 github.com/kyokomi/emoji v2.2.4+incompatible github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 @@ -99,6 +100,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect @@ -152,7 +154,7 @@ require ( golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.9.0 // indirect diff --git a/go.sum b/go.sum index 6d788894..0eb6b704 100644 --- a/go.sum +++ b/go.sum @@ -220,6 +220,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ionos-cloud/sdk-go/v6 v6.3.2 h1:2mUmrZZz6cPyT9IRX0T8fBLc/7XU/eTxP2Y5tS7/09k= github.com/ionos-cloud/sdk-go/v6 v6.3.2/go.mod h1:SXrO9OGyWjd2rZhAhEpdYN6VUAODzzqRdqA9BCviQtI= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -438,8 +442,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 055a53f8..6cdebc10 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -50,6 +50,11 @@ var ( BootstrapModule string ) +var ( + TestVerbose bool + TestType string +) + func InitDefaultFlagSet() *pflag.FlagSet { defaults := pflag.NewFlagSet("defaults for all commands", pflag.ExitOnError) @@ -108,3 +113,13 @@ func InitBootstrapFlagSet() *pflag.FlagSet { return bootstrap } + +func InitTestFlagSet() *pflag.FlagSet { + testFlags := pflag.NewFlagSet("test", pflag.ContinueOnError) + + testFlags.BoolVarP(&TestVerbose, "verbose", "v", false, "verbose output") + testFlags.StringVarP(&TestType, "type", "t", "", "test type to run (e.g., conversions)") + testFlags.StringVarP(&LogLevel, "log-level", "l", "INFO", "log-level [DEBUG | INFO | WARN | ERROR]") + + return testFlags +} diff --git a/internal/test/conversions/conversions.go b/internal/test/conversions/conversions.go new file mode 100644 index 00000000..2e7565ee --- /dev/null +++ b/internal/test/conversions/conversions.go @@ -0,0 +1,274 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conversions + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/itchyny/gojq" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/dmt/internal/test" +) + +const ( + conversionsFolder = "openapi/conversions" + testCasesFile = "conversions_test.yaml" +) + +// TestCase represents a single conversion test case +type TestCase struct { + Name string `yaml:"name" json:"name"` + Settings string `yaml:"settings" json:"settings"` + Expected string `yaml:"expected" json:"expected"` + CurrentVersion int `yaml:"currentVersion" json:"currentVersion"` + ExpectedVersion int `yaml:"expectedVersion" json:"expectedVersion"` +} + +// TestCasesFile represents the structure of the test cases YAML file +type TestCasesFile struct { + Cases []TestCase `yaml:"cases" json:"cases"` +} + +// Tester implements the test.Tester interface for conversion tests +type Tester struct{} + +// NewTester creates a new conversions tester +func NewTester() *Tester { + return &Tester{} +} + +// Type returns the test type +func (*Tester) Type() test.TestType { + return test.TestTypeConversions +} + +// CanRun checks if conversion tests can be run for the given module path +func (*Tester) CanRun(modulePath string) bool { + testFilePath := filepath.Join(modulePath, conversionsFolder, testCasesFile) + _, err := os.Stat(testFilePath) + return err == nil +} + +// Run executes the conversion tests for the given module path +func (*Tester) Run(modulePath string) (*test.TestSuiteResult, error) { + conversionsPath := filepath.Join(modulePath, conversionsFolder) + testFilePath := filepath.Join(conversionsPath, testCasesFile) + + // Read test cases + testCases, err := readTestCases(testFilePath) + if err != nil { + return nil, fmt.Errorf("failed to read test cases: %w", err) + } + + // Create converter + converter, err := newConverter(conversionsPath) + if err != nil { + return nil, fmt.Errorf("failed to create converter: %w", err) + } + + result := &test.TestSuiteResult{ + Type: test.TestTypeConversions, + Module: filepath.Base(modulePath), + Results: make([]test.TestResult, 0, len(testCases.Cases)), + } + + for _, tc := range testCases.Cases { + tr := test.TestResult{ + Name: tc.Name, + Passed: true, + } + + err := runTestCase(converter, tc) + if err != nil { + tr.Passed = false + tr.Message = err.Error() + } + + result.Results = append(result.Results, tr) + } + + return result, nil +} + +func readTestCases(path string) (*TestCasesFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var testCases TestCasesFile + if err := yaml.Unmarshal(data, &testCases); err != nil { + return nil, err + } + + return &testCases, nil +} + +func runTestCase(converter *Converter, tc TestCase) error { + settings, err := readSettings(tc.Settings) + if err != nil { + return fmt.Errorf("failed to parse settings: %w", err) + } + + _, converted, err := converter.ConvertTo(tc.CurrentVersion, tc.ExpectedVersion, settings) + if err != nil { + return fmt.Errorf("conversion failed: %w", err) + } + + expected, err := readSettings(tc.Expected) + if err != nil { + return fmt.Errorf("failed to parse expected: %w", err) + } + + marshaledConverted, err := json.Marshal(converted) + if err != nil { + return fmt.Errorf("failed to marshal converted: %w", err) + } + + marshaledExpected, err := json.Marshal(expected) + if err != nil { + return fmt.Errorf("failed to marshal expected: %w", err) + } + + if !bytes.Equal(marshaledConverted, marshaledExpected) { + return fmt.Errorf("mismatch:\n expected: %s\n got: %s", marshaledExpected, marshaledConverted) + } + + return nil +} + +func readSettings(settings string) (map[string]any, error) { + var parsed map[string]any + err := yaml.Unmarshal([]byte(settings), &parsed) + return parsed, err +} + +// Converter handles conversion between config versions +type Converter struct { + latest int + conversions map[int]string +} + +func newConverter(pathToConversions string) (*Converter, error) { + c := &Converter{conversions: make(map[int]string), latest: 1} + + conversionsDir, err := os.ReadDir(pathToConversions) + if err != nil { + return nil, err + } + + for _, file := range conversionsDir { + if file.IsDir() { + continue + } + + ext := filepath.Ext(file.Name()) + if ext != ".yaml" && ext != ".yml" { + continue + } + + // Skip test files + if file.Name() == testCasesFile { + continue + } + + v, conversion, err := readConversion(filepath.Join(pathToConversions, file.Name())) + if err != nil { + return nil, err + } + + if v > c.latest { + c.latest = v + } + c.conversions[v] = conversion + } + + return c, nil +} + +func readConversion(path string) (int, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, "", err + } + + var parsed struct { + Version int `yaml:"version"` + Conversions []string `yaml:"conversions"` + } + + if err := yaml.Unmarshal(data, &parsed); err != nil { + return 0, "", err + } + + return parsed.Version, strings.Join(parsed.Conversions, " | "), nil +} + +// ConvertTo converts settings from currentVersion to version +func (c *Converter) ConvertTo(currentVersion, version int, settings map[string]any) (int, map[string]any, error) { + if currentVersion == c.latest || settings == nil || c.conversions == nil { + return currentVersion, settings, nil + } + + if version == 0 { + version = c.latest + } + + var err error + for currentVersion++; currentVersion <= version; currentVersion++ { + if settings, err = c.convert(currentVersion, settings); err != nil { + return currentVersion, nil, err + } + } + + return c.latest, settings, err +} + +func (c *Converter) convert(version int, settings map[string]any) (map[string]any, error) { + conversion := c.conversions[version] + if conversion == "" { + return nil, errors.New("conversion not found") + } + + query, err := gojq.Parse(conversion) + if err != nil { + return nil, err + } + + v, _ := query.Run(settings).Next() + if v == nil { + return nil, nil + } + + if err, ok := v.(error); ok { + return nil, err + } + + filtered, ok := v.(map[string]any) + if !ok { + return nil, errors.New("cannot unmarshal after converting") + } + + return filtered, nil +} diff --git a/internal/test/conversions/conversions_test.go b/internal/test/conversions/conversions_test.go new file mode 100644 index 00000000..5e4a4bba --- /dev/null +++ b/internal/test/conversions/conversions_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conversions + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/internal/test" +) + +func TestTester_Type(t *testing.T) { + tester := NewTester() + assert.Equal(t, test.TestTypeConversions, tester.Type()) +} + +func TestTester_CanRun(t *testing.T) { + // Create a temp directory with proper structure + tmpDir := t.TempDir() + + tester := NewTester() + + // Without test file - should return false + assert.False(t, tester.CanRun(tmpDir)) + + // Create conversions folder and test file + conversionsDir := filepath.Join(tmpDir, "openapi", "conversions") + err := os.MkdirAll(conversionsDir, 0755) + require.NoError(t, err) + + testFile := filepath.Join(conversionsDir, testCasesFile) + err = os.WriteFile(testFile, []byte("cases: []"), 0600) + require.NoError(t, err) + + // With test file - should return true + assert.True(t, tester.CanRun(tmpDir)) +} + +func TestTester_Run(t *testing.T) { + // Create a temp directory with full test setup + tmpDir := t.TempDir() + conversionsDir := filepath.Join(tmpDir, "openapi", "conversions") + err := os.MkdirAll(conversionsDir, 0755) + require.NoError(t, err) + + // Create conversion file v2.yaml + conversionContent := `version: 2 +description: + en: "Test conversion" + ru: "Тестовая конверсия" +conversions: + - del(.removeMe) +` + err = os.WriteFile(filepath.Join(conversionsDir, "v2.yaml"), []byte(conversionContent), 0600) + require.NoError(t, err) + + // Create test cases file + testCasesContent := `cases: + - name: "should remove field" + currentVersion: 1 + expectedVersion: 2 + settings: | + keepMe: value + removeMe: gone + expected: | + keepMe: value +` + err = os.WriteFile(filepath.Join(conversionsDir, testCasesFile), []byte(testCasesContent), 0600) + require.NoError(t, err) + + tester := NewTester() + result, err := tester.Run(tmpDir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, test.TestTypeConversions, result.Type) + assert.Len(t, result.Results, 1) + assert.True(t, result.Results[0].Passed) + assert.Equal(t, "should remove field", result.Results[0].Name) +} + +func TestTester_Run_FailingTest(t *testing.T) { + // Create a temp directory with failing test + tmpDir := t.TempDir() + conversionsDir := filepath.Join(tmpDir, "openapi", "conversions") + err := os.MkdirAll(conversionsDir, 0755) + require.NoError(t, err) + + // Create conversion file v2.yaml + conversionContent := `version: 2 +description: + en: "Test conversion" + ru: "Тестовая конверсия" +conversions: + - del(.removeMe) +` + err = os.WriteFile(filepath.Join(conversionsDir, "v2.yaml"), []byte(conversionContent), 0600) + require.NoError(t, err) + + // Create test cases file with wrong expected value + testCasesContent := `cases: + - name: "should fail - wrong expected" + currentVersion: 1 + expectedVersion: 2 + settings: | + keepMe: value + removeMe: gone + expected: | + keepMe: value + removeMe: gone +` + err = os.WriteFile(filepath.Join(conversionsDir, testCasesFile), []byte(testCasesContent), 0600) + require.NoError(t, err) + + tester := NewTester() + result, err := tester.Run(tmpDir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Results, 1) + assert.False(t, result.Results[0].Passed) + assert.Contains(t, result.Results[0].Message, "mismatch") +} + +func TestConverter_ConvertTo(t *testing.T) { + // Create a temp directory with conversion files + tmpDir := t.TempDir() + + // Create v2 conversion + v2Content := `version: 2 +conversions: + - del(.field1) +` + err := os.WriteFile(filepath.Join(tmpDir, "v2.yaml"), []byte(v2Content), 0600) + require.NoError(t, err) + + // Create v3 conversion + v3Content := `version: 3 +conversions: + - del(.field2) +` + err = os.WriteFile(filepath.Join(tmpDir, "v3.yaml"), []byte(v3Content), 0600) + require.NoError(t, err) + + converter, err := newConverter(tmpDir) + require.NoError(t, err) + + settings := map[string]any{ + "keep": "value", + "field1": "remove1", + "field2": "remove2", + } + + // Convert from v1 to v2 + _, result, err := converter.ConvertTo(1, 2, settings) + require.NoError(t, err) + + _, hasField1 := result["field1"] + assert.False(t, hasField1, "field1 should be removed") + assert.Equal(t, "remove2", result["field2"], "field2 should remain") + assert.Equal(t, "value", result["keep"], "keep should remain") + + // Convert from v1 to v3 + settings = map[string]any{ + "keep": "value", + "field1": "remove1", + "field2": "remove2", + } + _, result, err = converter.ConvertTo(1, 3, settings) + require.NoError(t, err) + + _, hasField1 = result["field1"] + _, hasField2 := result["field2"] + assert.False(t, hasField1, "field1 should be removed") + assert.False(t, hasField2, "field2 should be removed") + assert.Equal(t, "value", result["keep"], "keep should remain") +} + +func TestReadTestCases(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), testCasesFile) + + content := `cases: + - name: "test1" + currentVersion: 1 + expectedVersion: 2 + settings: | + key: value + expected: | + key: value + - name: "test2" + currentVersion: 2 + expectedVersion: 3 + settings: | + another: setting + expected: | + another: setting +` + err := os.WriteFile(tmpFile, []byte(content), 0600) + require.NoError(t, err) + + testCases, err := readTestCases(tmpFile) + require.NoError(t, err) + assert.Len(t, testCases.Cases, 2) + assert.Equal(t, "test1", testCases.Cases[0].Name) + assert.Equal(t, 1, testCases.Cases[0].CurrentVersion) + assert.Equal(t, 2, testCases.Cases[0].ExpectedVersion) +} diff --git a/internal/test/runner.go b/internal/test/runner.go new file mode 100644 index 00000000..c7eb2d50 --- /dev/null +++ b/internal/test/runner.go @@ -0,0 +1,229 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fatih/color" +) + +// TestType represents a type of test that can be run +type TestType string + +const ( + TestTypeConversions TestType = "conversions" +) + +// TestResult represents the result of a single test case +type TestResult struct { + Name string + Passed bool + Message string +} + +// TestSuiteResult represents the result of a test suite +type TestSuiteResult struct { + Type TestType + Module string + Results []TestResult +} + +// Tester is the interface that all test implementations must implement +type Tester interface { + // Type returns the test type + Type() TestType + // CanRun checks if this tester can run for the given module path + CanRun(modulePath string) bool + // Run executes the tests for the given module path + Run(modulePath string) (*TestSuiteResult, error) +} + +// Runner manages and executes tests +type Runner struct { + testers []Tester +} + +// NewRunner creates a new test runner +func NewRunner() *Runner { + return &Runner{ + testers: make([]Tester, 0), + } +} + +// Register adds a tester to the runner +func (r *Runner) Register(t Tester) { + r.testers = append(r.testers, t) +} + +// RunOptions contains options for running tests +type RunOptions struct { + ModulePath string + TestTypes []TestType // empty means all + Verbose bool +} + +// Summary contains the overall test results +type Summary struct { + TotalSuites int + PassedSuites int + FailedSuites int + TotalTests int + PassedTests int + FailedTests int + Results []*TestSuiteResult +} + +// AddResult adds a test suite result and updates counters +func (s *Summary) AddResult(result *TestSuiteResult) { + s.Results = append(s.Results, result) + s.TotalSuites++ + + s.TotalTests += result.Total() + s.PassedTests += result.PassedCount() + s.FailedTests += result.FailedCount() + + if result.IsPassed() { + s.PassedSuites++ + } else { + s.FailedSuites++ + } +} + +// Total returns the total number of tests in the suite +func (r *TestSuiteResult) Total() int { + return len(r.Results) +} + +// PassedCount returns the number of passed tests +func (r *TestSuiteResult) PassedCount() int { + count := 0 + for _, tr := range r.Results { + if tr.Passed { + count++ + } + } + return count +} + +// FailedCount returns the number of failed tests +func (r *TestSuiteResult) FailedCount() int { + return r.Total() - r.PassedCount() +} + +// IsPassed returns true if all tests in the suite passed +func (r *TestSuiteResult) IsPassed() bool { + return r.FailedCount() == 0 +} + +// Run executes tests based on the provided options +func (r *Runner) Run(opts RunOptions) (*Summary, error) { + modulePath, err := filepath.Abs(opts.ModulePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + if _, err := os.Stat(modulePath); os.IsNotExist(err) { + return nil, fmt.Errorf("module path does not exist: %s", modulePath) + } + + summary := &Summary{ + Results: make([]*TestSuiteResult, 0), + } + + for _, tester := range r.testers { + if !matchesTestType(opts.TestTypes, tester.Type()) { + continue + } + + if !tester.CanRun(modulePath) { + continue + } + + result, err := tester.Run(modulePath) + if err != nil { + return nil, fmt.Errorf("test %s failed: %w", tester.Type(), err) + } + + if result != nil { + summary.AddResult(result) + } + } + + return summary, nil +} + +// matchesTestType returns true if types is empty (run all) or contains the given type +func matchesTestType(types []TestType, t TestType) bool { + if len(types) == 0 { + return true + } + for _, tt := range types { + if tt == t { + return true + } + } + return false +} + +// PrintSummary prints the test summary to stdout +func PrintSummary(summary *Summary) { + green := color.New(color.FgGreen) + red := color.New(color.FgRed) + yellow := color.New(color.FgYellow) + bold := color.New(color.Bold) + + fmt.Println() + bold.Println("Test Results:") + fmt.Println("─────────────────────────────────────────") + + for _, suite := range summary.Results { + fmt.Printf("\n📦 Module: %s\n", suite.Module) + fmt.Printf(" Test Type: %s\n", suite.Type) + + for _, result := range suite.Results { + if result.Passed { + green.Printf(" ✓ %s\n", result.Name) + } else { + red.Printf(" ✗ %s\n", result.Name) + if result.Message != "" { + red.Printf(" → %s\n", result.Message) + } + } + } + } + + fmt.Println() + fmt.Println("─────────────────────────────────────────") + bold.Printf("Summary: ") + + if summary.FailedTests == 0 { + green.Printf("%d passed", summary.PassedTests) + } else { + green.Printf("%d passed", summary.PassedTests) + fmt.Print(", ") + red.Printf("%d failed", summary.FailedTests) + } + + fmt.Printf(" (%d total)\n", summary.TotalTests) + + if summary.TotalSuites == 0 { + yellow.Println("No tests found") + } +} diff --git a/internal/test/runner_test.go b/internal/test/runner_test.go new file mode 100644 index 00000000..309e43b4 --- /dev/null +++ b/internal/test/runner_test.go @@ -0,0 +1,138 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockTester is a mock implementation of Tester for testing +type mockTester struct { + testType TestType + canRun bool + results *TestSuiteResult + runErr error +} + +func (m *mockTester) Type() TestType { + return m.testType +} + +func (m *mockTester) CanRun(_ string) bool { + return m.canRun +} + +func (m *mockTester) Run(_ string) (*TestSuiteResult, error) { + return m.results, m.runErr +} + +func TestRunner_Register(t *testing.T) { + runner := NewRunner() + assert.Empty(t, runner.testers) + + runner.Register(&mockTester{testType: TestTypeConversions}) + assert.Len(t, runner.testers, 1) +} + +func TestRunner_Run(t *testing.T) { + tmpDir := t.TempDir() + + runner := NewRunner() + runner.Register(&mockTester{ + testType: TestTypeConversions, + canRun: true, + results: &TestSuiteResult{ + Type: TestTypeConversions, + Module: "test-module", + Results: []TestResult{ + {Name: "test1", Passed: true}, + {Name: "test2", Passed: false, Message: "failed"}, + }, + }, + }) + + summary, err := runner.Run(RunOptions{ + ModulePath: tmpDir, + }) + + require.NoError(t, err) + assert.Equal(t, 1, summary.TotalSuites) + assert.Equal(t, 0, summary.PassedSuites) + assert.Equal(t, 1, summary.FailedSuites) + assert.Equal(t, 2, summary.TotalTests) + assert.Equal(t, 1, summary.PassedTests) + assert.Equal(t, 1, summary.FailedTests) +} + +func TestRunner_Run_FilterByType(t *testing.T) { + tmpDir := t.TempDir() + + runner := NewRunner() + runner.Register(&mockTester{ + testType: TestTypeConversions, + canRun: true, + results: &TestSuiteResult{ + Type: TestTypeConversions, + Module: "test-module", + Results: []TestResult{ + {Name: "test1", Passed: true}, + }, + }, + }) + runner.Register(&mockTester{ + testType: "other-type", + canRun: true, + results: &TestSuiteResult{ + Type: "other-type", + Module: "test-module", + Results: []TestResult{ + {Name: "other-test", Passed: true}, + }, + }, + }) + + // Filter to only conversions + summary, err := runner.Run(RunOptions{ + ModulePath: tmpDir, + TestTypes: []TestType{TestTypeConversions}, + }) + + require.NoError(t, err) + assert.Equal(t, 1, summary.TotalSuites) + assert.Equal(t, 1, summary.TotalTests) +} + +func TestRunner_Run_SkipsCannotRun(t *testing.T) { + tmpDir := t.TempDir() + + runner := NewRunner() + runner.Register(&mockTester{ + testType: TestTypeConversions, + canRun: false, // This tester cannot run + }) + + summary, err := runner.Run(RunOptions{ + ModulePath: tmpDir, + }) + + require.NoError(t, err) + assert.Equal(t, 0, summary.TotalSuites) + assert.Equal(t, 0, summary.TotalTests) +}