diff --git a/.editorconfig b/.editorconfig index a74f8abc2..99b9d18fe 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,6 +2,27 @@ insert_final_newline = true end_of_line = lf +# Binary files (override to ensure no text-related rules are applied) +[*.{png,jpg,gif,svg,pdf,ai,eps,mp4}] +insert_final_newline = false +end_of_line = unset +indent_style = unset +indent_size = unset + +# Override for machine generated binary HTML files in Screengrabs directory +[website/src/components/Screengrabs/**/*.html] +insert_final_newline = false +end_of_line = unset +indent_style = unset +indent_size = unset + +# Override for machine generated binary files in tests/snapshots directory for golden snapshots +[test/snapshots/**/*.golden] +insert_final_newline = false +end_of_line = unset +indent_style = unset +indent_size = unset + # Override for Makefile [{Makefile,makefile,GNUmakefile}] indent_style = tab diff --git a/.gitattributes b/.gitattributes index cc0fb28eb..2cd2cbb0f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,9 @@ docs linguist-documentation=true # Screengrabs are binary HTML files that are automatically generated website/src/components/Screengrabs/**/*.html linguist-generated=true binary +# Golden snapshots should be treated a raw output to prevent line-ending conversions +tests/snapshots/**/*.golden linguist-generated=true -text + # Mark binary files to prevent normalization *.png binary *.svg binary @@ -15,4 +18,9 @@ website/src/components/Screengrabs/**/*.html linguist-generated=true binary *.ai binary *.eps binary *.ansi binary -*.mp4 binary \ No newline at end of file +*.mp4 binary + +# Reduce merge conflicts that can occur when go.mod and go.sum files are updated +# Run `go mod tidy` to update the go.sum file +go.sum linguist-generated=true merge=union +go.mod linguist-generated=true merge=union diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce2d45555..a9ef4ee29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,6 +72,25 @@ jobs: path: | ./build/ + tidy: + name: Tidy Go Modules + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + id: go + + - uses: j0hnsmith/go-mod-check@v1 + with: + working-directory: ${{ github.workspace }} + # optional, only if you're happy to have `replace ` lines in your go.mod file + skip-replace-check: true + # run acceptance tests test: name: Acceptance Tests @@ -140,8 +159,23 @@ jobs: run: | make deps + # Enable this after merging test-cases + # Only seems to work with remote schema files + #- name: Validate YAML Schema for Test Cases + # uses: InoUno/yaml-ls-check@v1.4.0 + # with: + # root: "tests/test-cases" + # schemaMapping: | + # { + # "schema.json": [ + # "**/*.yaml" + # ] + # } + - name: Acceptance tests timeout-minutes: 10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: make testacc docker: @@ -352,6 +386,8 @@ jobs: - name: Run tests in ${{ matrix.demo-folder }} for ${{ matrix.flavor.target }} working-directory: ${{ matrix.demo-folder }} if: matrix.flavor.target == 'linux' || matrix.flavor.target == 'macos' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | atmos test @@ -369,6 +405,8 @@ jobs: working-directory: ${{ matrix.demo-folder }} if: matrix.flavor.target == 'windows' shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | atmos test @@ -446,7 +484,7 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Validate YAML Schema + - name: Validate YAML Schema for Stacks uses: InoUno/yaml-ls-check@v1.4.0 with: root: "examples/${{ matrix.demo-folder }}/stacks" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..1644c0dbe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "yaml.customTags": [ + "!not scalar" + ], + "[mdx]": { "editor.defaultFormatter": null, "editor.formatOnSave": false }, +} diff --git a/Makefile b/Makefile index 7c1bcba5c..5f4db0b44 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ # This works because `go list ./...` excludes vendor directories by default in modern versions of Go (1.11+). # No need for grep or additional filtering. TEST ?= $$(go list ./...) +TESTARGS ?= SHELL := /bin/bash #GOOS=darwin #GOOS=linux diff --git a/cmd/docs.go b/cmd/docs.go index 40a672b98..b4f1a8b7b 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -116,6 +116,12 @@ var docsCmd = &cobra.Command{ // Opens atmos.tools docs if no component argument is provided var err error + + if os.Getenv("GO_TEST") == "1" { + u.LogDebug(atmosConfig, "Skipping browser launch in test environment") + return // Skip launching the browser + } + switch runtime.GOOS { case "linux": err = exec.Command("xdg-open", atmosDocsURL).Start() diff --git a/cmd/root.go b/cmd/root.go index 460ed4b84..84eff8c9a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,6 +39,29 @@ var RootCmd = &cobra.Command{ cmd.SilenceUsage = true cmd.SilenceErrors = true } + + logsLevel, _ := cmd.Flags().GetString("logs-level") + logsFile, _ := cmd.Flags().GetString("logs-file") + + errorConfig := schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: logsLevel, + File: logsFile, + }, + } + + configAndStacksInfo := schema.ConfigAndStacksInfo{ + LogsLevel: logsLevel, + LogsFile: logsFile, + } + + // Only validate the config, don't store it yet since commands may need to add more info + _, err := cfg.InitCliConfig(configAndStacksInfo, false) + if err != nil { + if !errors.Is(err, cfg.NotFound) { + u.LogErrorAndExit(errorConfig, err) + } + } }, Run: func(cmd *cobra.Command, args []string) { // Check Atmos configuration diff --git a/cmd/version.go b/cmd/version.go index 75dbfe4d4..7c205bb63 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -28,7 +28,9 @@ var versionCmd = &cobra.Command{ u.LogErrorAndExit(schema.AtmosConfiguration{}, err) } - u.PrintMessage(fmt.Sprintf("\U0001F47D Atmos %s on %s/%s", version.Version, runtime.GOOS, runtime.GOARCH)) + atmosIcon := "\U0001F47D" + + u.PrintMessage(fmt.Sprintf("%s Atmos %s on %s/%s", atmosIcon, version.Version, runtime.GOOS, runtime.GOARCH)) fmt.Println() if checkFlag { @@ -45,7 +47,10 @@ var versionCmd = &cobra.Command{ } latestRelease := strings.TrimPrefix(latestReleaseTag, "v") currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { + + if latestRelease == currentRelease { + u.PrintMessage(fmt.Sprintf("You are running the latest version of Atmos (%s)", latestRelease)) + } else { u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) } } diff --git a/go.mod b/go.mod index a08f0cd1e..7b20eb106 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/log v0.4.0 github.com/editorconfig-checker/editorconfig-checker/v3 v3.0.3 + github.com/creack/pty v1.1.23 github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 github.com/go-git/go-git/v5 v5.13.1 @@ -31,13 +32,13 @@ require ( github.com/hashicorp/hcl/v2 v2.23.0 github.com/hashicorp/terraform-config-inspect v0.0.0-20241129133400-c404f8227ea6 github.com/hashicorp/terraform-exec v0.21.0 + github.com/hexops/gotextdiff v1.0.3 github.com/ivanpirog/coloredcobra v1.0.1 github.com/jfrog/jfrog-client-go v1.49.0 github.com/json-iterator/go v1.1.12 github.com/jwalton/go-supportscolor v1.2.0 github.com/kubescape/go-git-url v0.0.30 github.com/lrstanley/bubblezone v0.0.0-20250110055121-b45205ce63e2 - github.com/mattn/go-isatty v0.0.20 github.com/mikefarah/yq/v4 v4.45.1 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 @@ -48,6 +49,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/samber/lo v1.47.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 @@ -209,6 +211,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect @@ -245,7 +248,6 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.0 // indirect diff --git a/internal/exec/vendor_component_utils.go b/internal/exec/vendor_component_utils.go index 493bea958..761c52a84 100644 --- a/internal/exec/vendor_component_utils.go +++ b/internal/exec/vendor_component_utils.go @@ -16,8 +16,8 @@ import ( "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" "github.com/hairyhenderson/gomplate/v3" - "github.com/mattn/go-isatty" cp "github.com/otiai10/copy" + "golang.org/x/term" ) // findComponentConfigFile identifies the component vendoring config file (`component.yaml` or `component.yml`) @@ -359,5 +359,7 @@ func ExecuteComponentVendorInternal( // CheckTTYSupport checks if stdout supports TTY for displaying the progress UI. func CheckTTYSupport() bool { - return isatty.IsTerminal(os.Stdout.Fd()) + fd := int(os.Stdout.Fd()) + isTerminal := term.IsTerminal(fd) + return isTerminal } diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index a6dba2e49..694728da7 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -65,25 +65,32 @@ func TestNewLoggerFromCliConfig(t *testing.T) { func TestParseLogLevel(t *testing.T) { tests := []struct { - input string - expected LogLevel - err bool + name string + input string + expected LogLevel + expectError bool }{ - {"Trace", LogLevelTrace, false}, - {"Debug", LogLevelDebug, false}, - {"Info", LogLevelInfo, false}, - {"Warning", LogLevelWarning, false}, - {"Invalid", LogLevelInfo, true}, + {"Empty string returns Info", "", LogLevelInfo, false}, + {"Valid Trace level", "Trace", LogLevelTrace, false}, + {"Valid Debug level", "Debug", LogLevelDebug, false}, + {"Valid Info level", "Info", LogLevelInfo, false}, + {"Valid Warning level", "Warning", LogLevelWarning, false}, + {"Valid Off level", "Off", LogLevelOff, false}, + {"Invalid lowercase level", "trace", "", true}, + {"Invalid mixed case level", "TrAcE", "", true}, + {"Invalid level", "InvalidLevel", "", true}, + {"Invalid empty spaces", " ", "", true}, + {"Invalid special characters", "Debug!", "", true}, } for _, test := range tests { - t.Run(fmt.Sprintf("input=%s", test.input), func(t *testing.T) { - logLevel, err := ParseLogLevel(test.input) - if test.err { + t.Run(test.name, func(t *testing.T) { + level, err := ParseLogLevel(test.input) + if test.expectError { assert.Error(t, err) } else { assert.NoError(t, err) - assert.Equal(t, test.expected, logLevel) + assert.Equal(t, test.expected, level) } }) } @@ -168,3 +175,165 @@ func TestLogger_SetLogLevel(t *testing.T) { assert.NoError(t, err) assert.Equal(t, LogLevelDebug, logger.LogLevel) } + +func TestLogger_isLevelEnabled(t *testing.T) { + tests := []struct { + name string + currentLevel LogLevel + checkLevel LogLevel + expectEnabled bool + }{ + {"Trace enables all levels", LogLevelTrace, LogLevelTrace, true}, + {"Trace enables Debug", LogLevelTrace, LogLevelDebug, true}, + {"Trace enables Info", LogLevelTrace, LogLevelInfo, true}, + {"Trace enables Warning", LogLevelTrace, LogLevelWarning, true}, + {"Debug disables Trace", LogLevelDebug, LogLevelTrace, false}, + {"Debug enables Debug", LogLevelDebug, LogLevelDebug, true}, + {"Debug enables Info", LogLevelDebug, LogLevelInfo, true}, + {"Debug enables Warning", LogLevelDebug, LogLevelWarning, true}, + {"Info disables Trace", LogLevelInfo, LogLevelTrace, false}, + {"Info disables Debug", LogLevelInfo, LogLevelDebug, false}, + {"Info enables Info", LogLevelInfo, LogLevelInfo, true}, + {"Info enables Warning", LogLevelInfo, LogLevelWarning, true}, + {"Warning disables Trace", LogLevelWarning, LogLevelTrace, false}, + {"Warning disables Debug", LogLevelWarning, LogLevelDebug, false}, + {"Warning disables Info", LogLevelWarning, LogLevelInfo, false}, + {"Warning enables Warning", LogLevelWarning, LogLevelWarning, true}, + {"Off disables all levels", LogLevelOff, LogLevelTrace, false}, + {"Off disables Debug", LogLevelOff, LogLevelDebug, false}, + {"Off disables Info", LogLevelOff, LogLevelInfo, false}, + {"Off disables Warning", LogLevelOff, LogLevelWarning, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + logger := &Logger{LogLevel: test.currentLevel} + enabled := logger.isLevelEnabled(test.checkLevel) + assert.Equal(t, test.expectEnabled, enabled) + }) + } +} + +func TestLogger_LogMethods(t *testing.T) { + tests := []struct { + name string + loggerLevel LogLevel + message string + expectOutput bool + logFunc func(*Logger, string) + }{ + {"Trace logs when level is Trace", LogLevelTrace, "trace message", true, (*Logger).Trace}, + {"Trace doesn't log when level is Debug", LogLevelDebug, "trace message", false, (*Logger).Trace}, + {"Debug logs when level is Trace", LogLevelTrace, "debug message", true, (*Logger).Debug}, + {"Debug logs when level is Debug", LogLevelDebug, "debug message", true, (*Logger).Debug}, + {"Debug doesn't log when level is Info", LogLevelInfo, "debug message", false, (*Logger).Debug}, + {"Info logs when level is Trace", LogLevelTrace, "info message", true, (*Logger).Info}, + {"Info logs when level is Debug", LogLevelDebug, "info message", true, (*Logger).Info}, + {"Info logs when level is Info", LogLevelInfo, "info message", true, (*Logger).Info}, + {"Info doesn't log when level is Warning", LogLevelWarning, "info message", false, (*Logger).Info}, + {"Warning logs when level is Trace", LogLevelTrace, "warning message", true, (*Logger).Warning}, + {"Warning logs when level is Warning", LogLevelWarning, "warning message", true, (*Logger).Warning}, + {"Nothing logs when level is Off", LogLevelOff, "any message", false, (*Logger).Info}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Pipe to capture output + r, w, _ := os.Pipe() + oldStdout := os.Stdout + os.Stdout = w + + // Channel to capture output + outC := make(chan string) + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + logger, _ := NewLogger(test.loggerLevel, "/dev/stdout") + test.logFunc(logger, test.message) + + // Close the writer and restore stdout + w.Close() + os.Stdout = oldStdout + + // Read the output + output := <-outC + + if test.expectOutput { + assert.Contains(t, output, test.message) + } else { + assert.Empty(t, output) + } + }) + } +} + +func TestLoggerFromCliConfig(t *testing.T) { + tests := []struct { + name string + config schema.AtmosConfiguration + expectError bool + }{ + { + name: "Valid config with Info level", + config: schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: "Info", + File: "/dev/stdout", + }, + }, + expectError: false, + }, + { + name: "Valid config with Trace level", + config: schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: "Trace", + File: "/dev/stdout", + }, + }, + expectError: false, + }, + { + name: "Invalid log level", + config: schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: "Invalid", + File: "/dev/stdout", + }, + }, + expectError: true, + }, + { + name: "Empty log level defaults to Info", + config: schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: "", + File: "/dev/stdout", + }, + }, + expectError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + logger, err := NewLoggerFromCliConfig(test.config) + if test.expectError { + assert.Error(t, err) + assert.Nil(t, logger) + } else { + assert.NoError(t, err) + assert.NotNil(t, logger) + if test.config.Logs.Level == "" { + assert.Equal(t, LogLevelInfo, logger.LogLevel) + } else { + assert.Equal(t, LogLevel(test.config.Logs.Level), logger.LogLevel) + } + assert.Equal(t, test.config.Logs.File, logger.File) + } + }) + } +} diff --git a/pkg/utils/log_utils.go b/pkg/utils/log_utils.go index 63907a6e1..bdfc4d026 100644 --- a/pkg/utils/log_utils.go +++ b/pkg/utils/log_utils.go @@ -37,8 +37,7 @@ func LogErrorAndExit(atmosConfig schema.AtmosConfiguration, err error) { // Find the executed command's exit code from the error var exitError *exec.ExitError if errors.As(err, &exitError) { - exitCode := exitError.ExitCode() - os.Exit(exitCode) + os.Exit(exitError.ExitCode()) } os.Exit(1) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..32d49a316 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,109 @@ +# Tests + +We have automated tests in packages, as well as standalone tests in this directory. + +Smoke tests are implemented to verify the basic functionality and expected behavior of the compiled `atmos` binary, simulating real-world usage scenarios. + +```shell +├── cli_test.go # Responsible for smoke testing +├── fixtures/ +│ ├── components/ +│ │ └── terraform/ # Components that are conveniently reused for tests +│ ├── scenarios/ # Test scenarios consisting of stack configurations (valid & invalid) +│ │ ├── complete/ # Advanced stack configuration with both broken and valid stacks +│ │ ├── metadata/ # Test configurations for `metadata.*` +│ │ └── relative-paths/ # Test configurations for relative imports +│ └── schemas/ # Schemas used for JSON validation +├── snapshots/ # Golden snapshots (what we expect output to look like) +│ ├── TestCLICommands.stderr.golden +│ └── TestCLICommands_which_atmos.stdout.golden +└── test-cases/ + ├── complete.yaml + ├── core.yaml + ├── demo-custom-command.yaml + ├── demo-stacks.yaml + ├── demo-vendoring.yaml + ├── metadata.yaml + ├── relative-paths.yaml + └── schema.json # JSON schema for validation + +``` + +## Test Cases + +Our convention is to implement a test-case configuration file per scenario. Then place all smoke tests related to that scenario in the file. + +### Environment Variables + +The tests will automatically set some environment variables: + +- `GO_TEST=1` is always set, so commands in atmos can disable certain functionality during tests +- `TERM` is set when `tty: true` to emulate a proper terminal + +### Flags + +To regenerate ALL snapshots pass the `-regenerate-snaphosts` flag. + +> ![WARNING] +> +> #### This will regenerate all the snapshots +> +> After regenerating, make sure to review the differences: +> +> ```shell +> git diff tests/snapshots +> ``` + +To regenerate the snapshots for a specific test, just run: + +(replace `TestCLICommands/check_atmos_--help_in_empty-dir` with your test name) + +```shell +go test ./tests -v -run 'TestCLICommands/check_atmos_--help_in_empty-dir' -timeout 2m -regenerate-snapshots +``` + +After generating new golden snapshots, don't forget to add them. + +```shell +git add tests/snapshots/* +``` + +### Example Configuration + +We support an explicit type `!not` on the `expect.stdout` and `expect.stderr` sections (not on `expect.diff`) + +Snapshots are enabled by setting the `snapshots` flag, and using the `expect.diff` to ignore line-level differences. If no differences are expected, use an empty list. Note, things like paths will change between local development and CI, so some differences are often expected. + +We recommend testing incorrect usage with `expect.exit_code` of non-zero. For example, passing unsupported arguments. + +```yaml +# yaml-language-server: $schema=schema.json + +tests: + - name: atmos circuit-breaker + description: > # Friendly description of what this test is verifying + Ensure atmos breaks the infinite loop when shell depth exceeds maximum (10). + + enabled: true # Whether or not to enable this check + + skip: # Conditions when to skip + os: !not windows # Do not run on Windows (e.g. PTY not supported) + # Use "darwin" for macOS + # Use "linux" for Linux ;) + + snapshot: true # Enable golden snapshot. Use together with `expect.diff` + + workdir: "fixtures/scenarios/complete/" # Location to execute command + env: + SOME_ENV: true # Set an environment variable called "SOME_ENV" + command: "atmos" # Command to run + args: # Arguments or flags passed to command + - "help" + + expect: # Assertions + diff: [] # List of expected differences + stdout: # Expected output to stdout or TTY. All TTY output is directed to stdout + stderr: # Expected output to stderr; + - "^$" # Expect no output + exit_code: 0 # Expected exit code +``` diff --git a/tests/cli_test.go b/tests/cli_test.go index 7b3859296..79ed0a494 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -3,26 +3,47 @@ package tests import ( "bytes" "errors" + "flag" "fmt" - "io/ioutil" "os" "os/exec" "path/filepath" // For resolving absolute paths "regexp" + "runtime" "strings" + "syscall" "testing" + "github.com/charmbracelet/lipgloss" + "github.com/creack/pty" + "github.com/hexops/gotextdiff" + "github.com/hexops/gotextdiff/myers" + "github.com/hexops/gotextdiff/span" + "github.com/muesli/termenv" + "github.com/sergi/go-diff/diffmatchpatch" + "golang.org/x/term" "gopkg.in/yaml.v3" ) +// Command-line flag for regenerating snapshots +var regenerateSnapshots = flag.Bool("regenerate-snapshots", false, "Regenerate all golden snapshots") +var startingDir string +var snapshotBaseDir string + +// Define styles using lipgloss +var ( + addedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // Green + removedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) // Red +) + type Expectation struct { - Stdout []string `yaml:"stdout"` - Stderr []string `yaml:"stderr"` - ExitCode int `yaml:"exit_code"` - FileExists []string `yaml:"file_exists"` - FileContains map[string][]string `yaml:"file_contains"` + Stdout []MatchPattern `yaml:"stdout"` // Expected stdout output + Stderr []MatchPattern `yaml:"stderr"` // Expected stderr output + ExitCode int `yaml:"exit_code"` // Expected exit code + FileExists []string `yaml:"file_exists"` // Files to validate + FileContains map[string][]MatchPattern `yaml:"file_contains"` // File contents to validate (file to patterns map) + Diff []string `yaml:"diff"` // Acceptable differences in snapshot } - type TestCase struct { Name string `yaml:"name"` Description string `yaml:"description"` @@ -32,14 +53,38 @@ type TestCase struct { Args []string `yaml:"args"` Env map[string]string `yaml:"env"` Expect Expectation `yaml:"expect"` + Tty bool `yaml:"tty"` + Snapshot bool `yaml:"snapshot"` + Skip struct { + OS MatchPattern `yaml:"os"` + } `yaml:"skip"` } type TestSuite struct { Tests []TestCase `yaml:"tests"` } +type MatchPattern struct { + Pattern string + Negate bool +} + +func (m *MatchPattern) UnmarshalYAML(value *yaml.Node) error { + switch value.Tag { + case "!!str": // Regular string + m.Pattern = value.Value + m.Negate = false + case "!not": // Negated pattern + m.Pattern = value.Value + m.Negate = true + default: + return fmt.Errorf("unsupported tag %q", value.Tag) + } + return nil +} + func loadTestSuite(filePath string) (*TestSuite, error) { - data, err := ioutil.ReadFile(filePath) + data, err := os.ReadFile(filePath) if err != nil { return nil, err } @@ -50,6 +95,39 @@ func loadTestSuite(filePath string) (*TestSuite, error) { return nil, err } + // Default `diff` and `snapshot` if not present + for i := range suite.Tests { + testCase := &suite.Tests[i] + + // Ensure defaults for optional fields + if testCase.Expect.Diff == nil { + testCase.Expect.Diff = []string{} + } + if !testCase.Snapshot { + testCase.Snapshot = false + } + + if testCase.Env == nil { + testCase.Env = make(map[string]string) + } + + // Convey to atmos that it's running in a test environment + testCase.Env["GO_TEST"] = "1" + + // Dynamically set GITHUB_TOKEN if not already set, to avoid rate limits + if token, exists := os.LookupEnv("GITHUB_TOKEN"); exists { + if _, alreadySet := testCase.Env["GITHUB_TOKEN"]; !alreadySet { + testCase.Env["GITHUB_TOKEN"] = token + } + } + + // Dynamically set TTY-related environment variables if `Tty` is true + if testCase.Tty { + // Set TTY-specific environment variables + testCase.Env["TERM"] = "xterm-256color" // Simulates terminal support + } + } + return &suite, nil } @@ -92,6 +170,109 @@ func (pm *PathManager) Apply() error { return os.Setenv("PATH", pm.GetPath()) } +// Determine if running in a CI environment +func isCIEnvironment() bool { + return os.Getenv("CI") != "" +} + +// sanitizeTestName converts t.Name() into a valid filename. +func sanitizeTestName(name string) string { + // Replace slashes with underscores + name = strings.ReplaceAll(name, "/", "_") + + // Remove or replace other problematic characters + invalidChars := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`) // Matches invalid filename characters + name = invalidChars.ReplaceAllString(name, "_") + + // Trim trailing periods and spaces (Windows-specific issue) + name = strings.TrimRight(name, " .") + + return name +} + +// Drop any lines matched by the ignore patterns so they do not affect the comparison +func applyIgnorePatterns(input string, patterns []string) string { + lines := strings.Split(input, "\n") // Split input into lines + var filteredLines []string // Store lines that don't match the patterns + + for _, line := range lines { + shouldIgnore := false + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + if re.MatchString(line) { // Check if the line matches the pattern + shouldIgnore = true + break // No need to check further patterns for this line + } + } + if !shouldIgnore { + filteredLines = append(filteredLines, line) // Add non-matching lines + } + } + + return strings.Join(filteredLines, "\n") // Join the filtered lines back into a string +} + +// Simulate TTY command execution with optional stdin and proper stdout redirection +func simulateTtyCommand(t *testing.T, cmd *exec.Cmd, input string) (string, error) { + ptmx, err := pty.Start(cmd) + if err != nil { + return "", fmt.Errorf("failed to start TTY: %v", err) + } + defer func() { _ = ptmx.Close() }() + + // t.Logf("PTY Fd: %d, IsTerminal: %v", ptmx.Fd(), term.IsTerminal(int(ptmx.Fd()))) + + if input != "" { + go func() { + _, _ = ptmx.Write([]byte(input)) + _ = ptmx.Close() // Ensure we close the input after writing + }() + } + + var buffer bytes.Buffer + done := make(chan error, 1) + go func() { + _, err := buffer.ReadFrom(ptmx) + done <- ptyError(err) // Wrap the error handling + }() + + err = cmd.Wait() + if err != nil { + t.Logf("Command execution error: %v", err) + } + + if readErr := <-done; readErr != nil { + return "", fmt.Errorf("failed to read PTY output: %v", readErr) + } + + output := buffer.String() + // t.Logf("Captured Output:\n%s", output) + + return output, nil +} + +// Linux kernel return EIO when attempting to read from a master pseudo +// terminal which no longer has an open slave. So ignore error here. +// See https://github.com/creack/pty/issues/21 +// See https://github.com/owenthereal/upterm/pull/11 +func ptyError(err error) error { + if pathErr, ok := err.(*os.PathError); !ok || pathErr.Err != syscall.EIO { + return err + } + return nil +} + +// Execute the command and return the exit code +func executeCommand(t *testing.T, cmd *exec.Cmd) int { + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + t.Fatalf("Command execution failed: %v", err) + } + return 0 +} + // loadTestSuites loads and merges all .yaml files from the test-cases directory func loadTestSuites(testCasesDir string) (*TestSuite, error) { var mergedSuite TestSuite @@ -115,120 +296,216 @@ func loadTestSuites(testCasesDir string) (*TestSuite, error) { return &mergedSuite, nil } -func TestCLICommands(t *testing.T) { +// Entry point for tests to parse flags and handle setup/teardown +func TestMain(m *testing.M) { + // Declare err in the function's scope + var err error + + // Ensure that Lipgloss uses terminal colors for tests + lipgloss.SetColorProfile(termenv.TrueColor) + // Capture the starting working directory - startingDir, err := os.Getwd() + startingDir, err = os.Getwd() + if err != nil { + fmt.Printf("Failed to get the current working directory: %v\n", err) + os.Exit(1) // Exit with a non-zero code to indicate failure + } + + fmt.Printf("Starting directory: %s\n", startingDir) + // Define the base directory for snapshots relative to startingDir + snapshotBaseDir = filepath.Join(startingDir, "snapshots") + + flag.Parse() // Parse command-line flags + os.Exit(m.Run()) +} + +func runCLICommandTest(t *testing.T, tc TestCase) { + defer func() { + // Change back to the original working directory after the test + if err := os.Chdir(startingDir); err != nil { + t.Fatalf("Failed to change back to the starting directory: %v", err) + } + }() + + // Change to the specified working directory + if tc.Workdir != "" { + err := os.Chdir(tc.Workdir) + if err != nil { + t.Fatalf("Failed to change directory to %q: %v", tc.Workdir, err) + } + } + + // Check if the binary exists + binaryPath, err := exec.LookPath(tc.Command) if err != nil { - t.Fatalf("Failed to get the current working directory: %v", err) + t.Fatalf("Binary not found: %s. Current PATH: %s", tc.Command, os.Getenv("PATH")) + } + + // Prepare the command + cmd := exec.Command(binaryPath, tc.Args...) + + // Set environment variables + envVars := os.Environ() + for key, value := range tc.Env { + envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) + } + cmd.Env = envVars + + var stdout, stderr bytes.Buffer + var exitCode int + + if tc.Tty { + // Run the command in TTY mode + ptyOutput, err := simulateTtyCommand(t, cmd, "") + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + // Capture the actual exit code + exitCode = exitErr.ExitCode() + } else { + t.Fatalf("Failed to simulate TTY command: %v", err) + } + } + stdout.WriteString(ptyOutput) + } else { + // Run the command in non-TTY mode + + // Attach stdout and stderr buffers for non-TTY execution + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + // Capture the actual exit code + exitCode = exitErr.ExitCode() + } else { + t.Fatalf("Failed to run command; Error %v", err) + } + } else { + // Successful command execution + exitCode = 0 + } + } + + // Validate outputs + if !verifyExitCode(t, tc.Expect.ExitCode, exitCode) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate stdout + if !verifyOutput(t, "stdout", stdout.String(), tc.Expect.Stdout) { + t.Errorf("Stdout mismatch for test: %s", tc.Name) + } + + // Validate stderr + if !verifyOutput(t, "stderr", stderr.String(), tc.Expect.Stderr) { + t.Errorf("Stderr mismatch for test: %s", tc.Name) + } + + // Validate file existence + if !verifyFileExists(t, tc.Expect.FileExists) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate file contents + if !verifyFileContains(t, tc.Expect.FileContains) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate snapshots + if !verifySnapshot(t, tc, stdout.String(), stderr.String(), *regenerateSnapshots) { + t.Errorf("Description: %s", tc.Description) } +} +func TestCLICommands(t *testing.T) { // Initialize PathManager and update PATH pathManager := NewPathManager() pathManager.Prepend("../build", "..") - err = pathManager.Apply() + err := pathManager.Apply() if err != nil { t.Fatalf("Failed to apply updated PATH: %v", err) } fmt.Printf("Updated PATH: %s\n", pathManager.GetPath()) - // Update the test suite loading + // Load test suite testSuite, err := loadTestSuites("test-cases") if err != nil { t.Fatalf("Failed to load test suites: %v", err) } for _, tc := range testSuite.Tests { - if !tc.Enabled { t.Logf("Skipping disabled test: %s", tc.Name) continue } - t.Run(tc.Name, func(t *testing.T) { - defer func() { - // Change back to the original working directory after the test - if err := os.Chdir(startingDir); err != nil { - t.Fatalf("Failed to change back to the starting directory: %v", err) - } - }() - - // Change to the specified working directory - if tc.Workdir != "" { - err := os.Chdir(tc.Workdir) - if err != nil { - t.Fatalf("Failed to change directory to %q: %v", tc.Workdir, err) - } - } - - // Check if the binary exists - binaryPath, err := exec.LookPath(tc.Command) - if err != nil { - t.Fatalf("Binary not found: %s. Current PATH: %s", tc.Command, pathManager.GetPath()) - } - - // Prepare the command - cmd := exec.Command(binaryPath, tc.Args...) - - // Set environment variables - envVars := os.Environ() - for key, value := range tc.Env { - envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) - } - cmd.Env = envVars - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + // Check OS condition for skipping + if !verifyOS(t, []MatchPattern{tc.Skip.OS}) { + t.Logf("Skipping test due to OS condition: %s", tc.Name) + continue + } - // Run the command - err = cmd.Run() + // Run with `t.Run` for non-TTY tests + t.Run(tc.Name, func(t *testing.T) { + runCLICommandTest(t, tc) + }) + } +} - // Validate exit code - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } - } - if exitCode != tc.Expect.ExitCode { - t.Errorf("Description: %s", tc.Description) - t.Errorf("Reason: Expected exit code %d, got %d", tc.Expect.ExitCode, exitCode) - } +func verifyOS(t *testing.T, osPatterns []MatchPattern) bool { + currentOS := runtime.GOOS // Get the current operating system + success := true - // Validate stdout - if !verifyOutput(t, "stdout", stdout.String(), tc.Expect.Stdout) { - t.Errorf("Description: %s", tc.Description) - } + for _, pattern := range osPatterns { + // Compile the regex pattern + re, err := regexp.Compile(pattern.Pattern) + if err != nil { + t.Logf("Invalid OS regex pattern: %q, error: %v", pattern.Pattern, err) + success = false + continue + } - // Validate stderr - if !verifyOutput(t, "stderr", stderr.String(), tc.Expect.Stderr) { - t.Errorf("Description: %s", tc.Description) - } + // Check if the current OS matches the pattern + match := re.MatchString(currentOS) + if pattern.Negate && match { + t.Logf("Reason: OS %q matched negated pattern %q.", currentOS, pattern.Pattern) + success = false + } else if !pattern.Negate && !match { + t.Logf("Reason: OS %q did not match pattern %q.", currentOS, pattern.Pattern) + success = false + } + } - // Validate file existence - if !verifyFileExists(t, tc.Expect.FileExists) { - t.Errorf("Description: %s", tc.Description) - } + return success +} - // Validate file contents - if !verifyFileContains(t, tc.Expect.FileContains) { - t.Errorf("Description: %s", tc.Description) - } - }) +func verifyExitCode(t *testing.T, expected, actual int) bool { + success := true + if expected != actual { + t.Errorf("Reason: Expected exit code %d, got %d", expected, actual) + success = false } + return success } -func verifyOutput(t *testing.T, outputType, output string, patterns []string) bool { +func verifyOutput(t *testing.T, outputType, output string, patterns []MatchPattern) bool { success := true for _, pattern := range patterns { - re, err := regexp.Compile(pattern) + re, err := regexp.Compile(pattern.Pattern) if err != nil { - t.Errorf("Invalid %s regex: %q, error: %v", outputType, pattern, err) + t.Errorf("Invalid %s regex: %q, error: %v", outputType, pattern.Pattern, err) success = false continue } - if !re.MatchString(output) { - t.Errorf("Reason: %s did not match pattern %q.", outputType, pattern) + + match := re.MatchString(output) + if pattern.Negate && match { + t.Errorf("Reason: %s unexpectedly matched negated pattern %q.", outputType, pattern.Pattern) + t.Errorf("Output: %q", output) + success = false + } else if !pattern.Negate && !match { + t.Errorf("Reason: %s did not match pattern %q.", outputType, pattern.Pattern) t.Errorf("Output: %q", output) success = false } @@ -247,28 +524,209 @@ func verifyFileExists(t *testing.T, files []string) bool { return success } -func verifyFileContains(t *testing.T, filePatterns map[string][]string) bool { +func verifyFileContains(t *testing.T, filePatterns map[string][]MatchPattern) bool { success := true for file, patterns := range filePatterns { - content, err := ioutil.ReadFile(file) + content, err := os.ReadFile(file) if err != nil { t.Errorf("Reason: Failed to read file %q: %v", file, err) success = false continue } - for _, pattern := range patterns { - re, err := regexp.Compile(pattern) + for _, matchPattern := range patterns { + re, err := regexp.Compile(matchPattern.Pattern) if err != nil { - t.Errorf("Invalid regex for file %q: %q, error: %v", file, pattern, err) + t.Errorf("Invalid regex for file %q: %q, error: %v", file, matchPattern.Pattern, err) success = false continue } - if !re.Match(content) { - t.Errorf("Reason: File %q did not match pattern %q.", file, pattern) - t.Errorf("Content: %q", string(content)) - success = false + if matchPattern.Negate { + // Negated pattern: Ensure the pattern does NOT match + if re.Match(content) { + t.Errorf("Reason: File %q unexpectedly matched negated pattern %q.", file, matchPattern.Pattern) + t.Errorf("Content: %q", string(content)) + success = false + } + } else { + // Regular pattern: Ensure the pattern matches + if !re.Match(content) { + t.Errorf("Reason: File %q did not match pattern %q.", file, matchPattern.Pattern) + t.Errorf("Content: %q", string(content)) + success = false + } } } } return success } + +func updateSnapshot(fullPath, output string) { + err := os.MkdirAll(filepath.Dir(fullPath), 0755) // Ensure parent directories exist + if err != nil { + panic(fmt.Sprintf("Failed to create snapshot directory: %v", err)) + } + err = os.WriteFile(fullPath, []byte(output), 0644) // Write snapshot + if err != nil { + panic(fmt.Sprintf("Failed to write snapshot file: %v", err)) + } +} + +func readSnapshot(t *testing.T, fullPath string) string { + data, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("Error reading snapshot file %q: %v", fullPath, err) + } + return string(data) +} + +// Generate a unified diff using gotextdiff +func generateUnifiedDiff(actual, expected string) string { + edits := myers.ComputeEdits(span.URIFromPath("actual"), expected, actual) + unified := gotextdiff.ToUnified("expected", "actual", expected, edits) + + // Use a buffer to construct the colorized diff + var buf bytes.Buffer + for _, line := range strings.Split(fmt.Sprintf("%v", unified), "\n") { + switch { + case strings.HasPrefix(line, "+"): + // Apply green style for additions + fmt.Fprintln(&buf, addedStyle.Render(line)) + case strings.HasPrefix(line, "-"): + // Apply red style for deletions + fmt.Fprintln(&buf, removedStyle.Render(line)) + default: + // Keep other lines as-is + fmt.Fprintln(&buf, line) + } + } + return buf.String() +} + +// Generate a diff using diffmatchpatch +func DiffStrings(x, y string) string { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(x, y, false) + dmp.DiffCleanupSemantic(diffs) // Clean up the diff for readability + return dmp.DiffPrettyText(diffs) +} + +// Colorize diff output based on the threshold +func colorizeDiffWithThreshold(actual, expected string, threshold int) string { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(expected, actual, false) + dmp.DiffCleanupSemantic(diffs) + + var sb strings.Builder + for _, diff := range diffs { + text := diff.Text + switch diff.Type { + case diffmatchpatch.DiffInsert, diffmatchpatch.DiffDelete: + if len(text) < threshold { + // For short diffs, highlight entire line + sb.WriteString(fmt.Sprintf("\033[1m\033[33m%s\033[0m", text)) + } else { + // For long diffs, highlight at word/character level + color := "\033[32m" // Insert: green + if diff.Type == diffmatchpatch.DiffDelete { + color = "\033[31m" // Delete: red + } + sb.WriteString(fmt.Sprintf("%s%s\033[0m", color, text)) + } + case diffmatchpatch.DiffEqual: + sb.WriteString(text) + } + } + + return sb.String() +} + +func verifySnapshot(t *testing.T, tc TestCase, stdoutOutput, stderrOutput string, regenerate bool) bool { + if !tc.Snapshot { + return true + } + + testName := sanitizeTestName(t.Name()) + stdoutFileName := fmt.Sprintf("%s.stdout.golden", testName) + stderrFileName := fmt.Sprintf("%s.stderr.golden", testName) + stdoutPath := filepath.Join(snapshotBaseDir, stdoutFileName) + stderrPath := filepath.Join(snapshotBaseDir, stderrFileName) + + // Regenerate snapshots if the flag is set + if regenerate { + t.Logf("Updating stdout snapshot at %q", stdoutPath) + updateSnapshot(stdoutPath, stdoutOutput) + t.Logf("Updating stderr snapshot at %q", stderrPath) + updateSnapshot(stderrPath, stderrOutput) + return true + } + + // Verify stdout + if _, err := os.Stat(stdoutPath); errors.Is(err, os.ErrNotExist) { + t.Fatalf(`Stdout snapshot file not found: %q +Run the following command to create it: +$ go test -run=%q -regenerate-snapshots`, stdoutPath, t.Name()) + } + + filteredStdoutActual := applyIgnorePatterns(stdoutOutput, tc.Expect.Diff) + filteredStdoutExpected := applyIgnorePatterns(readSnapshot(t, stdoutPath), tc.Expect.Diff) + + if filteredStdoutExpected != filteredStdoutActual { + var diff string + if isCIEnvironment() || !term.IsTerminal(int(os.Stdout.Fd())) { + // Generate a colorized diff for better readability + diff = generateUnifiedDiff(filteredStdoutActual, filteredStdoutExpected) + + } else { + diff = colorizeDiffWithThreshold(filteredStdoutActual, filteredStdoutExpected, 10) + } + + t.Errorf("Stdout mismatch for %q:\n%s", stdoutPath, diff) + } + + // Verify stderr + if _, err := os.Stat(stderrPath); errors.Is(err, os.ErrNotExist) { + t.Fatalf(`Stderr snapshot file not found: %q +Run the following command to create it: +$ go test -run=%q -regenerate-snapshots`, stderrPath, t.Name()) + } + filteredStderrActual := applyIgnorePatterns(stderrOutput, tc.Expect.Diff) + filteredStderrExpected := applyIgnorePatterns(readSnapshot(t, stderrPath), tc.Expect.Diff) + + if filteredStderrExpected != filteredStderrActual { + var diff string + if isCIEnvironment() || !term.IsTerminal(int(os.Stdout.Fd())) { + diff = generateUnifiedDiff(filteredStderrActual, filteredStderrExpected) + } else { + // Generate a colorized diff for better readability + diff = colorizeDiffWithThreshold(filteredStderrActual, filteredStderrExpected, 10) + } + t.Errorf("Stderr mismatch for %q:\n%s", stdoutPath, diff) + } + + return true +} + +func TestUnmarshalMatchPattern(t *testing.T) { + yamlData := ` +expect: + stdout: + - "Normal output" + - !not "Negated pattern" +` + + type TestCase struct { + Expect struct { + Stdout []MatchPattern `yaml:"stdout"` + } `yaml:"expect"` + } + + var testCase TestCase + err := yaml.Unmarshal([]byte(yamlData), &testCase) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + + for i, pattern := range testCase.Expect.Stdout { + t.Logf("Pattern %d: %+v", i, pattern) + } +} diff --git a/tests/fixtures/scenarios/empty-dir/.gitignore b/tests/fixtures/scenarios/empty-dir/.gitignore new file mode 100644 index 000000000..72e8ffc0d --- /dev/null +++ b/tests/fixtures/scenarios/empty-dir/.gitignore @@ -0,0 +1 @@ +* diff --git a/tests/fixtures/invalid-log-level/atmos.yaml b/tests/fixtures/scenarios/invalid-log-level/atmos.yaml similarity index 100% rename from tests/fixtures/invalid-log-level/atmos.yaml rename to tests/fixtures/scenarios/invalid-log-level/atmos.yaml diff --git a/tests/fixtures/valid-log-level/atmos.yaml b/tests/fixtures/scenarios/valid-log-level/atmos.yaml similarity index 100% rename from tests/fixtures/valid-log-level/atmos.yaml rename to tests/fixtures/scenarios/valid-log-level/atmos.yaml diff --git a/tests/snapshots/TestCLICommands.stderr.golden b/tests/snapshots/TestCLICommands.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands.stdout.golden b/tests/snapshots/TestCLICommands.stdout.golden new file mode 100644 index 000000000..6ff96ac66 --- /dev/null +++ b/tests/snapshots/TestCLICommands.stdout.golden @@ -0,0 +1 @@ +/dev/ttys006 diff --git a/tests/snapshots/TestCLICommands_atmos.stderr.golden b/tests/snapshots/TestCLICommands_atmos.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos.stdout.golden b/tests/snapshots/TestCLICommands_atmos.stdout.golden new file mode 100644 index 000000000..219bb02fb --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos.stdout.golden @@ -0,0 +1,18 @@ + +atmos.yaml CLI config file specifies the directory for Atmos stacks as stacks, +but the directory does not exist. + +To configure and start using Atmos, refer to the following documents: + +Atmos CLI Configuration: +https://atmos.tools/cli/configuration + +Atmos Components: +https://atmos.tools/core-concepts/components + +Atmos Stacks: +https://atmos.tools/core-concepts/stacks + +Quick Start: +https://atmos.tools/quick-start + diff --git a/tests/snapshots/TestCLICommands_atmos_--help.stderr.golden b/tests/snapshots/TestCLICommands_atmos_--help.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden new file mode 100644 index 000000000..485f99f73 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden @@ -0,0 +1,57 @@ + + +Usage: + + atmos [flags] + atmos [command] + + +Available Commands: + + about Learn about Atmos + atlantis Generate and manage Atlantis configurations + aws Run AWS-specific commands for interacting with cloud resources + completion Generate autocompletion scripts for Bash, Zsh, Fish, and PowerShell + describe Show details about Atmos configurations and components + docs Open Atmos documentation or display component-specific docs + helmfile Manage Helmfile-based Kubernetes deployments + help Display help information for Atmos commands + list List available stacks and components + pro Access premium features integrated with app.cloudposse.com + support Show Atmos support options + terraform Execute Terraform commands (e.g., plan, apply, destroy) using Atmos stack configurations + validate Validate configurations against OPA policies and JSON schemas + vendor Manage external dependencies for components or stacks + version Display the version of Atmos you are running and check for updates + workflow Run predefined tasks using workflows + + +Flags: + + -h, --help help for atmos (default "false") + + --logs-file string The file to write Atmos logs to. Logs can be + written to any file or any standard file + descriptor, including '/dev/stdout', + '/dev/stderr' and '/dev/null' (default + "/dev/stdout") + + --logs-level string Logs level. Supported log levels are Trace, + Debug, Info, Warning, Off. If the log level + is set to Off, Atmos will not log any + messages (default "Info") + + --redirect-stderr string File descriptor to redirect 'stderr' to. + Errors can be redirected to any file or any + standard file descriptor (including + '/dev/null'): atmos + --redirect-stderr /dev/stdout + +The '--' (double-dash) can be used to signify the end of Atmos-specific options +and the beginning of additional native arguments and flags for the specific command being run. + +Example: + atmos atmos -s -- + + +Use "atmos [command] --help" for more information about a command. diff --git a/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stderr.golden b/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stderr.golden new file mode 100644 index 000000000..44f7baed6 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stderr.golden @@ -0,0 +1,11 @@ +ATMOS_SHLVL (11) exceeds maximum allowed depth (10). Infinite recursion? +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 +exit status 1 diff --git a/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stdout.golden b/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stdout.golden new file mode 100644 index 000000000..26c64b490 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_circuit-breaker.stdout.golden @@ -0,0 +1,10 @@ +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! +Hello world! diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stderr.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden new file mode 100644 index 000000000..1e3ddb9d0 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -0,0 +1,139 @@ +{ + "base_path": "./", + "components": { + "terraform": { + "base_path": "components/terraform", + "apply_auto_approve": false, + "append_user_agent": "Atmos/test (Cloud Posse; +https://atmos.tools)", + "deploy_run_init": true, + "init_run_reconfigure": true, + "auto_generate_backend_file": false, + "command": "", + "shell": { + "prompt": "" + } + }, + "helmfile": { + "base_path": "", + "use_eks": true, + "kubeconfig_path": "", + "helm_aws_profile_pattern": "", + "cluster_name_pattern": "", + "command": "" + } + }, + "stacks": { + "base_path": "stacks", + "included_paths": [ + "deploy/**/*" + ], + "excluded_paths": [ + "**/_defaults.yaml" + ], + "name_pattern": "{stage}", + "name_template": "" + }, + "workflows": { + "base_path": "" + }, + "logs": { + "file": "/dev/stderr", + "level": "Info" + }, + "integrations": { + "atlantis": {} + }, + "schemas": { + "jsonschema": {}, + "cue": {}, + "opa": {}, + "atmos": {} + }, + "templates": { + "settings": { + "enabled": false, + "sprig": { + "enabled": false + }, + "gomplate": { + "enabled": false, + "timeout": 0, + "datasources": null + } + } + }, + "settings": { + "list_merge_strategy": "", + "terminal": { + "max_width": 0, + "pager": false, + "colors": false, + "unicode": false, + "syntax_highlighting": { + "enabled": false, + "lexer": "", + "formatter": "", + "theme": "", + "pager": false, + "line_numbers": false, + "wrap": false + } + }, + "docs": { + "max_width": 0, + "pagination": false + }, + "markdown": { + "document": {}, + "block_quote": {}, + "paragraph": {}, + "list": {}, + "list_item": {}, + "heading": {}, + "h1": {}, + "h2": {}, + "h3": {}, + "h4": {}, + "h5": {}, + "h6": {}, + "text": {}, + "strong": {}, + "emph": {}, + "hr": {}, + "item": {}, + "enumeration": {}, + "code": {}, + "code_block": {}, + "table": {}, + "definition_list": {}, + "definition_term": {}, + "definition_description": {}, + "html_block": {}, + "html_span": {}, + "link": {}, + "link_text": {} + }, + "InjectGithubToken": true + }, + "vendor": { + "base_path": "" + }, + "initialized": true, + "stacksBaseAbsolutePath": "/Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks", + "includeStackAbsolutePaths": [ + "/Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks/deploy/**/*" + ], + "excludeStackAbsolutePaths": [ + "/Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks/**/_defaults.yaml" + ], + "terraformDirAbsolutePath": "/Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/components/terraform", + "helmfileDirAbsolutePath": "/Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks", + "default": false, + "version": { + "Check": { + "Enabled": false, + "Timeout": 0, + "Frequency": "" + } + } +} diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stderr.golden b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden new file mode 100644 index 000000000..d41b52220 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden @@ -0,0 +1,43 @@ +base_path: ./ +components: + terraform: + base_path: components/terraform + apply_auto_approve: false + append_user_agent: Atmos/test (Cloud Posse; +https://atmos.tools) + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + command: "" + shell: + prompt: "" + helmfile: + base_path: "" + use_eks: true + kubeconfig_path: "" + helm_aws_profile_pattern: "" + cluster_name_pattern: "" + command: "" +stacks: + base_path: stacks + included_paths: + - deploy/**/* + excluded_paths: + - '**/_defaults.yaml' + name_pattern: '{stage}' + name_template: "" +logs: + file: /dev/stderr + level: Info +settings: + list_merge_strategy: "" + inject_github_token: true +initialized: true +stacksBaseAbsolutePath: /Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks +includeStackAbsolutePaths: + - /Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks/deploy/**/* +excludeStackAbsolutePaths: + - /Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/stacks/**/_defaults.yaml +terraformDirAbsolutePath: /Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks/components/terraform +helmfileDirAbsolutePath: /Users/erik/Dev/cloudposse/tools/atmos/examples/demo-stacks +default: false + diff --git a/tests/snapshots/TestCLICommands_atmos_docs.stderr.golden b/tests/snapshots/TestCLICommands_atmos_docs.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_docs.stdout.golden b/tests/snapshots/TestCLICommands_atmos_docs.stdout.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_docs_myapp.stderr.golden b/tests/snapshots/TestCLICommands_atmos_docs_myapp.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_docs_myapp.stdout.golden b/tests/snapshots/TestCLICommands_atmos_docs_myapp.stdout.golden new file mode 100644 index 000000000..17cdc00ea --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_docs_myapp.stdout.golden @@ -0,0 +1,54 @@ + + # Example Terraform Weather Component + + This Terraform "root" module fetches weather information for a specified location with custom display options. + It queries data from the wttr.in https://wttr.in weather service and stores the result in a local file (cache.txt). + It also provides several outputs like weather information, request URL, stage, location, language, and units of + measurement. + + ## Features + + • Fetch weather updates for a location using HTTP request. + • Write the obtained weather data in a local file. + • Customizable display options. + • View the request URL. + • Get informed about the stage, location, language, and units in the metadata. + + ## Usage + + To include this module in your Atmos Stacks https://atmos.tools/core-concepts/stacks configuration: + + components: + terraform: + weather: + vars: + stage: dev + location: New York + options: 0T + format: v2 + lang: en + units: m + + ### Inputs + + • stage: Stage where it will be deployed. + • location: Location for which the weather is reported. Default is "Los Angeles". + • options: Options to customize the output. Default is "0T". + • format: Specifies the output format. Default is "v2". + • lang: Language in which the weather will be displayed. Default is "en". + • units: Units in which the weather will be displayed. Default is "m". + + ### Outputs + + • weather: The fetched weather data. + • url: Requested URL. + • stage: Stage of deployment. + • location: Location of the reported weather. + • lang: Language used for weather data. + • units: Units of measurement for the weather data. + + Please note, this module requires Terraform version >=1.0.0, and you need to specify no other required providers. + + Happy Weather Tracking! + + diff --git a/tests/snapshots/TestCLICommands_atmos_greet_with_args.stderr.golden b/tests/snapshots/TestCLICommands_atmos_greet_with_args.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_greet_with_args.stdout.golden b/tests/snapshots/TestCLICommands_atmos_greet_with_args.stdout.golden new file mode 100644 index 000000000..f193df1c7 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_greet_with_args.stdout.golden @@ -0,0 +1 @@ +Hello, Neo diff --git a/tests/snapshots/TestCLICommands_atmos_greet_without_args.stderr.golden b/tests/snapshots/TestCLICommands_atmos_greet_without_args.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_greet_without_args.stdout.golden b/tests/snapshots/TestCLICommands_atmos_greet_without_args.stdout.golden new file mode 100644 index 000000000..eaf6f1da6 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_greet_without_args.stdout.golden @@ -0,0 +1 @@ +Hello, John Doe diff --git a/tests/snapshots/TestCLICommands_atmos_non-existent.stderr.golden b/tests/snapshots/TestCLICommands_atmos_non-existent.stderr.golden new file mode 100644 index 000000000..cdf2b3ad8 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_non-existent.stderr.golden @@ -0,0 +1,3 @@ +Error: unknown command "non-existent" for "atmos" +Run 'atmos --help' for usage. +unknown command "non-existent" for "atmos" diff --git a/tests/snapshots/TestCLICommands_atmos_non-existent.stdout.golden b/tests/snapshots/TestCLICommands_atmos_non-existent.stdout.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_stacks_with_relative_paths.stderr.golden b/tests/snapshots/TestCLICommands_atmos_stacks_with_relative_paths.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_stacks_with_relative_paths.stdout.golden b/tests/snapshots/TestCLICommands_atmos_stacks_with_relative_paths.stdout.golden new file mode 100644 index 000000000..a0cd1f4ce --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_stacks_with_relative_paths.stdout.golden @@ -0,0 +1,97 @@ +acme-platform-dev: + components: + terraform: + myapp: + atmos_component: myapp + atmos_manifest: orgs/acme/platform/dev + atmos_stack: acme-platform-dev + atmos_stack_file: orgs/acme/platform/dev + backend: {} + backend_type: "" + command: terraform + component: myapp + env: {} + hooks: {} + inheritance: [] + metadata: {} + overrides: {} + providers: {} + remote_state_backend: {} + remote_state_backend_type: "" + settings: {} + stack: acme-platform-dev + vars: + format: "" + lang: se + location: Stockholm + namespace: acme + options: "0" + stage: dev + tenant: platform + units: m + workspace: acme-platform-dev +acme-platform-prod: + components: + terraform: + myapp: + atmos_component: myapp + atmos_manifest: orgs/acme/platform/prod + atmos_stack: acme-platform-prod + atmos_stack_file: orgs/acme/platform/prod + backend: {} + backend_type: "" + command: terraform + component: myapp + env: {} + hooks: {} + inheritance: [] + metadata: {} + overrides: {} + providers: {} + remote_state_backend: {} + remote_state_backend_type: "" + settings: {} + stack: acme-platform-prod + vars: + format: "" + lang: en + location: Los Angeles + namespace: acme + options: "0" + stage: prod + tenant: platform + units: m + workspace: acme-platform-prod +acme-platform-staging: + components: + terraform: + myapp: + atmos_component: myapp + atmos_manifest: orgs/acme/platform/staging + atmos_stack: acme-platform-staging + atmos_stack_file: orgs/acme/platform/staging + backend: {} + backend_type: "" + command: terraform + component: myapp + env: {} + hooks: {} + inheritance: [] + metadata: {} + overrides: {} + providers: {} + remote_state_backend: {} + remote_state_backend_type: "" + settings: {} + stack: acme-platform-staging + vars: + format: "" + lang: en + location: Los Angeles + namespace: acme + options: "0" + stage: staging + tenant: platform + units: m + workspace: acme-platform-staging + diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_deploy_locked_component.stderr.golden b/tests/snapshots/TestCLICommands_atmos_terraform_deploy_locked_component.stderr.golden new file mode 100644 index 000000000..7ac58c389 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_terraform_deploy_locked_component.stderr.golden @@ -0,0 +1 @@ +component 'myapp' is locked and cannot be modified (metadata.locked = true) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_deploy_locked_component.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_deploy_locked_component.stdout.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_validate_stacks_with_metadata.stderr.golden b/tests/snapshots/TestCLICommands_atmos_validate_stacks_with_metadata.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_validate_stacks_with_metadata.stdout.golden b/tests/snapshots/TestCLICommands_atmos_validate_stacks_with_metadata.stdout.golden new file mode 100644 index 000000000..c2fd27f15 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_validate_stacks_with_metadata.stdout.golden @@ -0,0 +1 @@ +all stacks validated successfully diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull.stderr.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull.stderr.golden new file mode 100644 index 000000000..3b96c511a --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull.stderr.golden @@ -0,0 +1,7 @@ +Vendoring from 'vendor.yaml' +No TTY detected. Falling back to basic output. This can happen when no terminal is attached or when commands are pipelined. +✓ github/stargazers (main) +✓ weather (main) +✓ ipinfo (main) +Vendored 3 components. + diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull.stdout.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull.stdout.golden new file mode 100644 index 000000000..1624c22d0 --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull.stdout.golden @@ -0,0 +1,2 @@ +CheckTTYSupport: os.Stdout.Fd() = 1, IsTerminal = false +CheckTTYSupport: os.Stdout.Fd() = 1, IsTerminal = false diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull_(no_tty).stderr.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull_(no_tty).stderr.golden new file mode 100644 index 000000000..3b96c511a --- /dev/null +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull_(no_tty).stderr.golden @@ -0,0 +1,7 @@ +Vendoring from 'vendor.yaml' +No TTY detected. Falling back to basic output. This can happen when no terminal is attached or when commands are pipelined. +✓ github/stargazers (main) +✓ weather (main) +✓ ipinfo (main) +Vendored 3 components. + diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull_(no_tty).stdout.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull_(no_tty).stdout.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_version.stderr.golden b/tests/snapshots/TestCLICommands_atmos_version.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_atmos_version_--check.stderr.golden b/tests/snapshots/TestCLICommands_atmos_version_--check.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stderr.golden b/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden b/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden new file mode 100644 index 000000000..485f99f73 --- /dev/null +++ b/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden @@ -0,0 +1,57 @@ + + +Usage: + + atmos [flags] + atmos [command] + + +Available Commands: + + about Learn about Atmos + atlantis Generate and manage Atlantis configurations + aws Run AWS-specific commands for interacting with cloud resources + completion Generate autocompletion scripts for Bash, Zsh, Fish, and PowerShell + describe Show details about Atmos configurations and components + docs Open Atmos documentation or display component-specific docs + helmfile Manage Helmfile-based Kubernetes deployments + help Display help information for Atmos commands + list List available stacks and components + pro Access premium features integrated with app.cloudposse.com + support Show Atmos support options + terraform Execute Terraform commands (e.g., plan, apply, destroy) using Atmos stack configurations + validate Validate configurations against OPA policies and JSON schemas + vendor Manage external dependencies for components or stacks + version Display the version of Atmos you are running and check for updates + workflow Run predefined tasks using workflows + + +Flags: + + -h, --help help for atmos (default "false") + + --logs-file string The file to write Atmos logs to. Logs can be + written to any file or any standard file + descriptor, including '/dev/stdout', + '/dev/stderr' and '/dev/null' (default + "/dev/stdout") + + --logs-level string Logs level. Supported log levels are Trace, + Debug, Info, Warning, Off. If the log level + is set to Off, Atmos will not log any + messages (default "Info") + + --redirect-stderr string File descriptor to redirect 'stderr' to. + Errors can be redirected to any file or any + standard file descriptor (including + '/dev/null'): atmos + --redirect-stderr /dev/stdout + +The '--' (double-dash) can be used to signify the end of Atmos-specific options +and the beginning of additional native arguments and flags for the specific command being run. + +Example: + atmos atmos -s -- + + +Use "atmos [command] --help" for more information about a command. diff --git a/tests/snapshots/TestCLICommands_check_atmos_in_empty-dir.stderr.golden b/tests/snapshots/TestCLICommands_check_atmos_in_empty-dir.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_check_atmos_in_empty-dir.stdout.golden b/tests/snapshots/TestCLICommands_check_atmos_in_empty-dir.stdout.golden new file mode 100644 index 000000000..ba5c32571 --- /dev/null +++ b/tests/snapshots/TestCLICommands_check_atmos_in_empty-dir.stdout.golden @@ -0,0 +1,20 @@ + +atmos.yaml CLI config file was not found. + +The default Atmos stacks directory is set to stacks, +but the directory does not exist in the current path. + +To configure and start using Atmos, refer to the following documents: + +Atmos CLI Configuration: +https://atmos.tools/cli/configuration + +Atmos Components: +https://atmos.tools/core-concepts/components + +Atmos Stacks: +https://atmos.tools/core-concepts/stacks + +Quick Start: +https://atmos.tools/quick-start + diff --git a/tests/snapshots/TestCLICommands_test_non-tty.stderr.golden b/tests/snapshots/TestCLICommands_test_non-tty.stderr.golden new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/TestCLICommands_test_non-tty.stdout.golden b/tests/snapshots/TestCLICommands_test_non-tty.stdout.golden new file mode 100644 index 000000000..eec3bdbef --- /dev/null +++ b/tests/snapshots/TestCLICommands_test_non-tty.stdout.golden @@ -0,0 +1 @@ +not a tty diff --git a/tests/test-cases/complete.yaml b/tests/test-cases/complete.yaml index 5b3f10e76..e60f25386 100644 --- a/tests/test-cases/complete.yaml +++ b/tests/test-cases/complete.yaml @@ -1,12 +1,16 @@ +# yaml-language-server: $schema=schema.json + tests: - name: atmos circuit-breaker enabled: true + snapshot: true description: "Ensure atmos breaks the infinite loop when shell depth exceeds maximum (10)." workdir: "fixtures/scenarios/complete/" command: "atmos" args: - "loop" expect: + diff: [] stderr: - "ATMOS_SHLVL.*exceeds maximum allowed depth.*(\r?\n)+exit status 1(\r?\n)+exit status 1.*" exit_code: 1 diff --git a/tests/test-cases/core.yaml b/tests/test-cases/core.yaml index 7b022f333..ce6288a33 100644 --- a/tests/test-cases/core.yaml +++ b/tests/test-cases/core.yaml @@ -1,6 +1,40 @@ +# yaml-language-server: $schema=schema.json + tests: + - name: "test tty" + enabled: true + snapshot: false + tty: true + description: "Ensure tty is enabled." + workdir: "../" + command: "tty" + skip: + # PTY not supported on windows + os: !not windows + expect: + stdout: + - "^(/dev)" + stderr: + - "^$" + exit_code: 0 + + - name: "test non-tty" + enabled: true + snapshot: false + tty: false + description: "Ensure tty is disabled." + workdir: "../" + command: "tty" + expect: + stdout: + - "^not a tty" + stderr: + - "^$" + exit_code: 1 + - name: "which atmos" enabled: true + snapshot: false description: "Ensure atmos CLI is installed and we're using the one that was built." workdir: "../" command: "which" @@ -8,17 +42,21 @@ tests: - "atmos" expect: stdout: - - '(build[/\\]atmos|atmos[/\\]atmos)' + # build/atmos is local + # atmos/atmos is in GitHub Actions + - '(build[/\\]atmos|atmos/atmos)' stderr: - "^$" exit_code: 0 - name: "atmos" enabled: true + snapshot: true description: "Verify atmos CLI reports missing stacks directory." workdir: "../" command: "atmos" expect: + diff: [] stdout: - "atmos.yaml CLI config file specifies the directory for Atmos stacks as stacks," - "but the directory does not exist." @@ -28,30 +66,35 @@ tests: - name: atmos docs enabled: true + snapshot: true description: "Ensure atmos docs command executes without errors." workdir: "../" command: "atmos" args: - "docs" expect: + diff: [] exit_code: 0 stderr: - "^$" - name: atmos non-existent enabled: true + snapshot: true description: "Ensure atmos CLI returns an error for a non-existent command." workdir: "../" command: "atmos" args: - "non-existent" expect: + diff: [] stderr: - 'unknown command "non-existent" for "atmos"' exit_code: 1 - name: atmos terraform non-existent enabled: false + snapshot: true description: "Ensure atmos CLI returns an error for a non-existent command." workdir: "../" command: "atmos" @@ -59,6 +102,7 @@ tests: - "terraform" - "non-existent" expect: + diff: [] stderr: - 'unknown command "non-existent" for "atmos"' exit_code: 1 diff --git a/tests/test-cases/demo-custom-command.yaml b/tests/test-cases/demo-custom-command.yaml index e5cd2f83b..c13ede9bc 100644 --- a/tests/test-cases/demo-custom-command.yaml +++ b/tests/test-cases/demo-custom-command.yaml @@ -1,6 +1,8 @@ +# yaml-language-server: $schema=schema.json tests: - name: atmos greet with args enabled: true + snapshot: true description: "Validate atmos custom command greet runs with argument provided." workdir: "../examples/demo-custom-command/" command: "atmos" @@ -8,6 +10,7 @@ tests: - "greet" - "Neo" expect: + diff: [] stdout: - "Hello, Neo\n" stderr: @@ -16,12 +19,14 @@ tests: - name: atmos greet without args enabled: true + snapshot: true description: "Validate atmos custom command greet runs without argument provided." workdir: "../examples/demo-custom-command/" command: "atmos" args: - "greet" expect: + diff: [] stdout: - "Hello, John Doe\n" stderr: diff --git a/tests/test-cases/demo-stacks.yaml b/tests/test-cases/demo-stacks.yaml index a50c6d89f..f2c7867cc 100644 --- a/tests/test-cases/demo-stacks.yaml +++ b/tests/test-cases/demo-stacks.yaml @@ -1,12 +1,15 @@ +# yaml-language-server: $schema=schema.json tests: - name: atmos --help enabled: true + snapshot: true description: "Ensure atmos CLI help command lists available commands." workdir: "../examples/demo-stacks" command: "atmos" args: - "--help" expect: + diff: [] stdout: - "Available Commands:" - "\\batlantis\\b" @@ -31,6 +34,7 @@ tests: - name: atmos version enabled: true + snapshot: false description: "Check that atmos version command outputs version details." workdir: "../examples/demo-stacks" command: "atmos" @@ -45,8 +49,11 @@ tests: - name: atmos version --check enabled: true + snapshot: false description: "Verify atmos version --check command functions correctly." workdir: "../examples/demo-stacks" + env: + ATMOS_LOGS_LEVEL: "Warning" command: "atmos" args: - "version" @@ -54,12 +61,15 @@ tests: expect: stdout: - '👽 Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9]+' + - 'Atmos Releases: https://github\.com/cloudposse/atmos/releases' + - 'Install Atmos: https://atmos\.tools/install' stderr: - "^$" exit_code: 0 - name: atmos docs myapp enabled: true + snapshot: true description: "Validate atmos docs command outputs documentation for a specific component." workdir: "../examples/demo-stacks/" command: "atmos" @@ -67,6 +77,7 @@ tests: - "docs" - "myapp" expect: + diff: [] stdout: - "Example Terraform Weather Component" stderr: @@ -74,6 +85,7 @@ tests: exit_code: 0 - name: atmos describe config -f yaml + snapshot: true enabled: true description: "Ensure atmos CLI outputs the Atmos configuration in YAML." workdir: "../examples/demo-stacks/" @@ -84,6 +96,12 @@ tests: - "-f" - "yaml" expect: + diff: + - "stacksBaseAbsolutePath" + - "terraformDirAbsolutePath" + - "helmfileDirAbsolutePath" + - 'examples[/\\]+demo-stacks[/\\]+stacks[/\\]+\*\*[/\\]+_defaults.yaml' + - 'examples[/\\]+demo-stacks[/\\]+stacks[/\\]deploy[/\\]+\*\*[/\\]+\*' stdout: - 'append_user_agent: Atmos/(\d+\.\d+\.\d+|test) \(Cloud Posse; \+https:\/\/atmos\.tools\)' stderr: @@ -92,6 +110,7 @@ tests: - name: atmos describe config enabled: true + snapshot: true description: "Ensure atmos CLI outputs the Atmos configuration in JSON." workdir: "../examples/demo-stacks/" command: "atmos" @@ -99,6 +118,13 @@ tests: - "describe" - "config" expect: + diff: + - '"append_user_agent": "Atmos/(\d+\.\d+\.\d+|test) \(Cloud Posse; \+https:\/\/atmos\.tools\)"' + - "stacksBaseAbsolutePath" + - "terraformDirAbsolutePath" + - "helmfileDirAbsolutePath" + - 'examples[/\\]+demo-stacks[/\\]+stacks[/\\]+\*\*[/\\]+_defaults.yaml' + - 'examples[/\\]+demo-stacks[/\\]+stacks[/\\]+deploy[/\\]+\*\*[/\\]+\*' stdout: - '"append_user_agent": "Atmos/(\d+\.\d+\.\d+|test) \(Cloud Posse; \+https:\/\/atmos\.tools\)"' stderr: diff --git a/tests/test-cases/demo-vendoring.yaml b/tests/test-cases/demo-vendoring.yaml new file mode 100644 index 000000000..15e754eb1 --- /dev/null +++ b/tests/test-cases/demo-vendoring.yaml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=schema.json + +tests: + - name: atmos vendor pull + enabled: true + snapshot: false + tty: true + description: "Vendoring works with pull command" + workdir: "../examples/demo-vendoring/" + command: "atmos" + args: + - "vendor" + - "pull" + skip: + # PTY not supported on windows + os: !not windows + expect: + stdout: + - "Pulling" + - "github/stargazers" + - "weather" + - "Vendored 3 components" + - "Vendoring from 'vendor.yaml'" + - !not 'No TTY detected\. Falling back to basic output\.' + exit_code: 0 + + - name: atmos vendor pull (no tty) + enabled: true + snapshot: true + tty: false + description: "Vendoring works with pull command" + workdir: "../examples/demo-vendoring/" + command: "atmos" + args: + - "vendor" + - "pull" + expect: + diff: [] + stderr: + - 'No TTY detected\. Falling back to basic output\.' + exit_code: 0 diff --git a/tests/test-cases/empty-dir.yaml b/tests/test-cases/empty-dir.yaml new file mode 100644 index 000000000..f0e1eabaa --- /dev/null +++ b/tests/test-cases/empty-dir.yaml @@ -0,0 +1,48 @@ +tests: + - name: check atmos version in empty-dir + enabled: true + snapshot: false + description: "Check that atmos version command outputs version details." + workdir: "fixtures/scenarios/empty-dir" + command: "atmos" + args: + - "version" + expect: + stdout: + - '👽 Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9]+' + stderr: + - "^$" + exit_code: 0 + + - name: check atmos in empty-dir + enabled: true + snapshot: true + description: "Check that atmos command outputs helpful information to get started" + workdir: "fixtures/scenarios/empty-dir" + command: "atmos" + args: [] + expect: + diff: [] + stdout: + - "atmos.yaml CLI config file was not found." + - "https://atmos.tools/cli/configuration" + stderr: + - "^$" + exit_code: 0 + + - name: check atmos --help in empty-dir + enabled: true + snapshot: true + description: "Check that atmos command outputs help even with no configuration" + workdir: "fixtures/scenarios/empty-dir" + command: "atmos" + args: [--help] + expect: + diff: [] + stdout: + - "Available Commands:" + - "Flags:" + - 'Use "atmos \[command\] --help" for more information about a command\.' + stderr: + - "^$" + exit_code: 0 diff --git a/tests/test-cases/log-level-validation.yaml b/tests/test-cases/log-level-validation.yaml index f7172a33b..e2742dc6d 100644 --- a/tests/test-cases/log-level-validation.yaml +++ b/tests/test-cases/log-level-validation.yaml @@ -2,7 +2,7 @@ tests: - name: "Invalid Log Level in Config File" enabled: true description: "Test validation of invalid log level in atmos.yaml config file" - workdir: "fixtures/invalid-log-level" + workdir: "fixtures/scenarios/invalid-log-level" command: "atmos" args: - terraform @@ -36,7 +36,7 @@ tests: - name: "Valid Log Level in Config File" enabled: true description: "Test validation of valid log level in atmos.yaml config file" - workdir: "fixtures/valid-log-level" + workdir: "fixtures/scenarios/valid-log-level" command: "atmos" args: - terraform @@ -77,4 +77,4 @@ tests: - -s - test expect: - exit_code: 0 \ No newline at end of file + exit_code: 0 diff --git a/tests/test-cases/metadata.yaml b/tests/test-cases/metadata.yaml index 2a77ed064..1db863432 100644 --- a/tests/test-cases/metadata.yaml +++ b/tests/test-cases/metadata.yaml @@ -1,6 +1,9 @@ +# yaml-language-server: $schema=schema.json + tests: - name: atmos validate stacks with metadata enabled: true + snapshot: true description: "Verify atmos validate stacks command works" workdir: "fixtures/scenarios/metadata" command: "atmos" @@ -8,12 +11,14 @@ tests: - "validate" - "stacks" expect: + diff: [] stdout: - "all stacks validated successfully" exit_code: 0 - name: atmos terraform deploy unlocked component enabled: true + snapshot: false description: "Verify terraform deploy works on unlocked component" workdir: "fixtures/scenarios/metadata" command: "atmos" @@ -28,6 +33,7 @@ tests: - name: atmos terraform deploy locked component enabled: true + snapshot: true description: "Verify terraform deploy fails on locked component" workdir: "fixtures/scenarios/metadata" command: "atmos" @@ -44,6 +50,7 @@ tests: - name: atmos terraform plan locked component enabled: true + snapshot: false description: "Verify terraform plan works on locked component" workdir: "fixtures/scenarios/metadata" command: "atmos" diff --git a/tests/test-cases/relative-paths.yaml b/tests/test-cases/relative-paths.yaml index 1774789f2..101fb2137 100644 --- a/tests/test-cases/relative-paths.yaml +++ b/tests/test-cases/relative-paths.yaml @@ -1,6 +1,8 @@ +# yaml-language-server: $schema=schema.json tests: - name: atmos stacks with relative paths enabled: true + snapshot: true description: "Verify atmos describe stacks command lists all stacks with their configurations when using relative paths with . and .. in imports" workdir: "fixtures/scenarios/relative-paths" command: "atmos" @@ -8,6 +10,7 @@ tests: - "describe" - "stacks" expect: + diff: [] stdout: - "acme-platform-dev:" - "acme-platform-staging:" diff --git a/tests/test-cases/schema.json b/tests/test-cases/schema.json new file mode 100644 index 000000000..a8f28f005 --- /dev/null +++ b/tests/test-cases/schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the test." + }, + "enabled": { + "type": "boolean", + "description": "Whether the test is enabled or not." + }, + "tty": { + "type": "boolean", + "description": "Whether to run the test with a TTY." + }, + "description": { + "type": "string", + "description": "A short description of the test." + }, + "workdir": { + "type": "string", + "description": "The working directory for the test." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The arguments to pass to the command.", + "default": [] + }, + "expect": { + "type": "object", + "properties": { + "stdout": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "not": { "type": "string" } + }, + "required": ["not"] + } + ] + }, + "description": "Expected patterns in stdout." + }, + "stderr": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Expected patterns in stderr." + }, + "exit_code": { + "type": "integer", + "description": "Expected exit code of the command." + } + }, + "required": ["exit_code"], + "description": "Expectations for the test." + } + }, + "required": ["name", "enabled", "workdir", "command", "expect"] + } + } + }, + "required": ["tests"] + }