diff --git a/_tests/integration/run_test.go b/_tests/integration/run_test.go new file mode 100644 index 00000000..83db2452 --- /dev/null +++ b/_tests/integration/run_test.go @@ -0,0 +1,145 @@ +package integration + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/bitrise-io/envman/env" + "github.com/bitrise-io/go-utils/pathutil" + "github.com/stretchr/testify/require" +) + +func TestRun(t *testing.T) { + tmpDir, err := pathutil.NormalizedOSTempDirPath("__envman__") + require.NoError(t, err) + + envstore := filepath.Join(tmpDir, ".envstore") + + for _, tt := range env.EnvmanSharedTestCases { + t.Run(tt.Name, func(t *testing.T) { + // Clear and init + err := EnvmanInitAtPath(envstore) + require.NoError(t, err, "EnvmanInitAtPath()") + + for _, envVar := range tt.Envs { + if err := envVar.FillMissingDefaults(); err != nil { + require.NoError(t, err, "FillMissingDefaults()") + } + } + + err = ExportEnvironmentsList(envstore, tt.Envs) + require.NoError(t, err, "ExportEnvironmentsList()") + + output, err := EnvmanRun(envstore, tmpDir, []string{"env"}) + require.NoError(t, err, "EnvmanRun()") + + gotOut, err := parseEnvRawOut(output) + require.NoError(t, err, "parseEnvRawOut()") + + // Want envs + envsWant := make(map[string]string) + for _, envVar := range os.Environ() { + key, value := env.SplitEnv(envVar) + envsWant[key] = value + } + + for _, envCommand := range tt.Want { + switch envCommand.Action { + case env.SetAction: + envsWant[envCommand.Variable.Key] = envCommand.Variable.Value + case env.UnsetAction: + delete(envsWant, envCommand.Variable.Key) + case env.SkipAction: + default: + t.Fatalf("compare() failed, invalid action: %d", envCommand.Action) + } + } + + require.Equal(t, envsWant, gotOut) + }) + } + +} + +// Used for tests only, to parse env command output +func parseEnvRawOut(output string) (map[string]string, error) { + // matches a single line like MYENVKEY_1=myvalue + // Shell uses upperscore letters (plus numbers and underscore); Step inputs are lowerscore. + // https://pubs.opengroup.org/onlinepubs/9699919799/: + // > Environment variable names used by the utilities in the Shell and Utilities volume of POSIX.1-2017 + // > consist solely of uppercase letters, digits, and the ( '_' ) from the characters defined + // > in Portable Character Set and do not begin with a digit. + // > Other characters may be permitted by an implementation; applications shall tolerate the presence of such names. + r := regexp.MustCompile("^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$") + + lines := strings.Split(output, "\n") + + envs := make(map[string]string) + lastKey := "" + for _, line := range lines { + match := r.FindStringSubmatch(line) + + // If no env is mathced, treat the line as the continuation of the env in the previous line. + // `env` command output does not distinguish between a new env in a new line and + // and environment value containing newline character. + // Newline can be added for example: ** myenv=A$'\n'B env ** (bash/zsh only) + // If called from a script step, the content of the script contains newlines: + /* + content=#!/usr/bin/env bash + set -ex + current_envman="..." + # ... + go test -v ./_tests/integration/..." + */ + if match == nil { + if lastKey != "" { + envs[lastKey] += "\n" + line + } + continue + } + + // If match not nil, must have 3 mathces at this point (the matched string and its subexpressions) + if len(match) != 3 { + return nil, fmt.Errorf("parseEnvRawOut() failed, match (%s) length is not 3 for line (%s).", match, line) + } + + lastKey = match[1] + envs[match[1]] = match[2] + } + + return envs, nil +} + +func Test_parseEnvRawOut(t *testing.T) { + tests := []struct { + name string + output string + want map[string]string + }{ + { + output: `RBENV_SHELL=zsh +_=/usr/local/bin/go +#!/bin/env bash +echo "ff" +A=`, + want: map[string]string{ + "RBENV_SHELL": "zsh", + "_": `/usr/local/bin/go +#!/bin/env bash +echo "ff"`, + "A": "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseEnvRawOut(tt.output) + require.NoError(t, err, "parseEnvRawOut()") + require.Equal(t, got, tt.want) + }) + } +} diff --git a/_tests/integration/utils.go b/_tests/integration/utils.go new file mode 100644 index 00000000..7492c269 --- /dev/null +++ b/_tests/integration/utils.go @@ -0,0 +1,101 @@ +package integration + +import ( + "os" + "os/exec" + "strings" + + "github.com/bitrise-io/envman/models" + "github.com/bitrise-io/go-utils/command" +) + +// EnvmanInitAtPath ... +func EnvmanInitAtPath(envstorePth string) error { + const logLevel = "debug" + args := []string{"--loglevel", logLevel, "--path", envstorePth, "init", "--clear"} + return command.RunCommand(binPath(), args...) +} + +// EnvmanAdd ... +func EnvmanAdd(envstorePth, key, value string, expand, skipIfEmpty bool) error { + const logLevel = "debug" + args := []string{"--loglevel", logLevel, "--path", envstorePth, "add", "--key", key, "--append"} + if !expand { + args = append(args, "--no-expand") + } + if skipIfEmpty { + args = append(args, "--skip-if-empty") + } + + envman := exec.Command(binPath(), args...) + envman.Stdin = strings.NewReader(value) + envman.Stdout = os.Stdout + envman.Stderr = os.Stderr + return envman.Run() +} + +// EnvmanAdd ... +func EnvmanUnset(envstorePth, key, value string, expand, skipIfEmpty bool) error { + const logLevel = "debug" + args := []string{"--loglevel", logLevel, "--path", envstorePth, "unset", "--key", key} + if !expand { + args = append(args, "--no-expand") + } + if skipIfEmpty { + args = append(args, "--skip-if-empty") + } + + envman := exec.Command(binPath(), args...) + envman.Stdin = strings.NewReader(value) + envman.Stdout = os.Stdout + envman.Stderr = os.Stderr + return envman.Run() +} + +// ExportEnvironmentsList ... +func ExportEnvironmentsList(envstorePth string, envsList []models.EnvironmentItemModel) error { + for _, env := range envsList { + key, value, err := env.GetKeyValuePair() + if err != nil { + return err + } + + opts, err := env.GetOptions() + if err != nil { + return err + } + + isExpand := models.DefaultIsExpand + if opts.IsExpand != nil { + isExpand = *opts.IsExpand + } + + skipIfEmpty := models.DefaultSkipIfEmpty + if opts.SkipIfEmpty != nil { + skipIfEmpty = *opts.SkipIfEmpty + } + + if opts.Unset != nil && *opts.Unset { + if err := EnvmanUnset(envstorePth, key, value, isExpand, skipIfEmpty); err != nil { + return err + } + continue + } + + if err := EnvmanAdd(envstorePth, key, value, isExpand, skipIfEmpty); err != nil { + return err + } + } + return nil +} + +// EnvmanRun runs a command through envman. +func EnvmanRun(envstorePth, workDir string, cmdArgs []string) (string, error) { + const logLevel = "panic" + args := []string{"--loglevel", logLevel, "--path", envstorePth, "run"} + args = append(args, cmdArgs...) + + cmd := command.New(binPath(), args...).SetDir(workDir) + + return cmd.RunAndReturnTrimmedCombinedOutput() +} diff --git a/bitrise.yml b/bitrise.yml index e8ac35bb..7c38df98 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -31,6 +31,10 @@ workflows: export PR="" PULL_REQUEST_ID="" export INTEGRATION_TEST_BINARY_PATH="$current_envman" + + # prevent the env var content (with the content of this script) + # being added to the test process environment + unset content go test -v ./_tests/integration/... create-binaries: diff --git a/cli/run.go b/cli/run.go index 6e47936b..2ed5049d 100644 --- a/cli/run.go +++ b/cli/run.go @@ -1,10 +1,10 @@ package cli import ( - "fmt" "os" log "github.com/Sirupsen/logrus" + "github.com/bitrise-io/envman/env" "github.com/bitrise-io/envman/envman" "github.com/bitrise-io/envman/models" "github.com/bitrise-io/go-utils/command" @@ -22,40 +22,18 @@ func expandEnvsInString(inp string) string { return os.ExpandEnv(inp) } -func commandEnvs(envs []models.EnvironmentItemModel) ([]string, error) { - for _, env := range envs { - key, value, err := env.GetKeyValuePair() - if err != nil { - return []string{}, err - } - - opts, err := env.GetOptions() - if err != nil { - return []string{}, err - } - - if opts.Unset != nil && *opts.Unset { - if err := os.Unsetenv(key); err != nil { - return []string{}, fmt.Errorf("unset env (%s): %s", key, err) - } - continue - } - - if *opts.SkipIfEmpty && value == "" { - continue - } - - var valueStr string - if *opts.IsExpand { - valueStr = expandEnvsInString(value) - } else { - valueStr = value - } +func commandEnvs(newEnvs []models.EnvironmentItemModel) ([]string, error) { + result, err := env.GetDeclarationsSideEffects(newEnvs, &env.DefaultEnvironmentSource{}) + if err != nil { + return nil, err + } - if err := os.Setenv(key, valueStr); err != nil { - return []string{}, err + for _, command := range result.CommandHistory { + if err := env.ExecuteCommand(command); err != nil { + return nil, err } } + return os.Environ(), nil } diff --git a/env/expand.go b/env/expand.go new file mode 100644 index 00000000..26894199 --- /dev/null +++ b/env/expand.go @@ -0,0 +1,195 @@ +package env + +import ( + "fmt" + "os" + "strings" + + "github.com/bitrise-io/envman/models" +) + +// Action is a possible action changing an environment variable +type Action int + +const ( + // InvalidAction represents an unexpected state + InvalidAction Action = iota + 1 + // SetAction is an environment variable assignement, like os.Setenv + SetAction + // UnsetAction is an action to clear (if existing) an environment variable, like os.Unsetenv + UnsetAction + // SkipAction means that no action is performed (usually for an env with an empty value) + SkipAction +) + +// Command describes an action performed on an envrionment variable +type Command struct { + Action Action + Variable Variable +} + +// Variable is an environment variable +type Variable struct { + Key string + Value string +} + +// DeclarationSideEffects is returned by GetDeclarationsSideEffects() +type DeclarationSideEffects struct { + // CommandHistory is an ordered list of commands: when performed in sequence, + // will result in a environment that contains the declared env vars + CommandHistory []Command + // ResultEnvironment is returned for reference, + // it will equal the environment after performing the commands + ResultEnvironment map[string]string +} + +// EnvironmentSource implementations can return an initial environment +type EnvironmentSource interface { + GetEnvironment() map[string]string +} + +// DefaultEnvironmentSource is a default implementation of EnvironmentSource, returns the current environment +type DefaultEnvironmentSource struct{} + +// GetEnvironment returns the current process' environment +func (*DefaultEnvironmentSource) GetEnvironment() map[string]string { + processEnvs := os.Environ() + envs := make(map[string]string) + + // String names can be duplicated (on Unix), and the Go libraries return the first instance of them: + // https://github.com/golang/go/blob/98d20fb23551a7ab900fcfe9d25fd9cb6a98a07f/src/syscall/env_unix.go#L45 + // From https://pubs.opengroup.org/onlinepubs/9699919799/: + // > "There is no meaning associated with the order of strings in the environment. + // > If more than one string in an environment of a process has the same name, the consequences are undefined." + for _, env := range processEnvs { + key, value := SplitEnv(env) + if key == "" { + continue + } + + envs[key] = value + } + + return envs +} + +// SplitEnv splits an env returned by os.Environ +func SplitEnv(env string) (key string, value string) { + const sep = "=" + split := strings.SplitAfterN(env, sep, 2) + if split == nil { + return "", "" + } + key = strings.TrimSuffix(split[0], sep) + if len(split) > 1 { + value = split[1] + } + return +} + +// GetDeclarationsSideEffects iterates over the list of ordered new declared variables sequentally and returns the needed +// commands (like os.Setenv) to add the variables to the current environment. +// The current process environment is not changed. +// Variable expansion is done also, every new variable can reference the previous and initial environments (via EnvironmentSource) +// The new variables (models.EnvironmentItemModel) can be defined in the envman definition file, or filled in directly. +// If the source of the variables (models.EnvironmentItemModel) is the bitrise.yml workflow, +// they will be in this order: +// - Bitrise CLI configuration paramters (IS_CI, IS_DEBUG) +// - App secrets +// - App level envs +// - Workflow level envs +// - Additional Step inputs envs (BITRISE_STEP_SOURCE_DIR; BitriseTestDeployDirEnvKey ("BITRISE_TEST_DEPLOY_DIR"), PWD) +// - Input envs +func GetDeclarationsSideEffects(newEnvs []models.EnvironmentItemModel, envSource EnvironmentSource) (DeclarationSideEffects, error) { + envs := envSource.GetEnvironment() + commandHistory := make([]Command, len(newEnvs)) + + for i, env := range newEnvs { + command, err := getDeclarationCommand(env, envs) + if err != nil { + return DeclarationSideEffects{}, fmt.Errorf("failed to parse new environment variable (%s): %s", env, err) + } + + commandHistory[i] = command + + switch command.Action { + case SetAction: + envs[command.Variable.Key] = command.Variable.Value + case UnsetAction: + delete(envs, command.Variable.Key) + case SkipAction: + default: + return DeclarationSideEffects{}, fmt.Errorf("invalid case for environement declaration action: %#v", command) + } + } + + return DeclarationSideEffects{ + CommandHistory: commandHistory, + ResultEnvironment: envs, + }, nil +} + +// getDeclarationCommand maps a variable to be daclered (env) to an expanded env key and value. +// The current process environment is not changed. +func getDeclarationCommand(env models.EnvironmentItemModel, envs map[string]string) (Command, error) { + envKey, envValue, err := env.GetKeyValuePair() + if err != nil { + return Command{}, fmt.Errorf("failed to get new environment variable name and value: %s", err) + } + + options, err := env.GetOptions() + if err != nil { + return Command{}, fmt.Errorf("failed to get new environment options: %s", err) + } + + if options.Unset != nil && *options.Unset { + return Command{ + Action: UnsetAction, + Variable: Variable{Key: envKey}, + }, nil + } + + if options.SkipIfEmpty != nil && *options.SkipIfEmpty && envValue == "" { + return Command{ + Action: SkipAction, + Variable: Variable{Key: envKey}, + }, nil + } + + mappingFuncFactory := func(envs map[string]string) func(string) string { + return func(key string) string { + if _, ok := envs[key]; !ok { + return "" + } + + return envs[key] + } + } + + if options.IsExpand != nil && *options.IsExpand { + envValue = os.Expand(envValue, mappingFuncFactory(envs)) + } + + return Command{ + Action: SetAction, + Variable: Variable{ + Key: envKey, + Value: envValue, + }, + }, nil +} + +// ExecuteCommand sets the current process's envrionment +func ExecuteCommand(command Command) error { + switch command.Action { + case SetAction: + return os.Setenv(command.Variable.Key, command.Variable.Value) + case UnsetAction: + return os.Unsetenv(command.Variable.Key) + case SkipAction: + return nil + default: + return fmt.Errorf("invalid case for environement declaration action: %#v", command) + } +} diff --git a/env/expand_test.go b/env/expand_test.go new file mode 100644 index 00000000..b4b8b308 --- /dev/null +++ b/env/expand_test.go @@ -0,0 +1,108 @@ +package env + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func restoreEnviron(environ []string) error { + currEnviron := os.Environ() + for _, currEnv := range currEnviron { + currEnvKey, _ := SplitEnv(currEnv) + if err := os.Unsetenv(currEnvKey); err != nil { + return err + } + } + + for _, envVar := range environ { + key, value := SplitEnv(envVar) + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("failed to set %s=%s: %s", key, value, err) + } + } + + return nil +} + +func TestGetDeclarationsSideEffects(t *testing.T) { + for _, test := range EnvmanSharedTestCases { + t.Run(test.Name, func(t *testing.T) { + // Arrange + cleanEnvs := os.Environ() + + for _, envVar := range test.Envs { + err := envVar.FillMissingDefaults() + require.NoError(t, err, "FillMissingDefaults()") + } + // Act + got, err := GetDeclarationsSideEffects(test.Envs, &DefaultEnvironmentSource{}) + require.NoError(t, err, "GetDeclarationsSideEffects()") + + err = restoreEnviron(cleanEnvs) + require.NoError(t, err, "restoreEnviron()") + + // Assert + require.NotNil(t, got) + require.Equal(t, test.Want, got.CommandHistory) + + // Want envs + envsWant := make(map[string]string) + for _, envVar := range os.Environ() { + key, value := SplitEnv(envVar) + envsWant[key] = value + } + + for _, envCommand := range got.CommandHistory { + switch envCommand.Action { + case SetAction: + envsWant[envCommand.Variable.Key] = envCommand.Variable.Value + case UnsetAction: + delete(envsWant, envCommand.Variable.Key) + case SkipAction: + default: + t.Fatalf("compare() failed, invalid action: %d", envCommand.Action) + } + } + + require.Equal(t, envsWant, got.ResultEnvironment) + }) + } +} + +func TestSplitEnv(t *testing.T) { + tests := []struct { + name string + env string + wantKey string + wantValue string + }{ + { + name: "simple case", + env: "A=B", + wantKey: "A", + wantValue: "B", + }, + { + name: "equals sign", + env: "A==B", + wantKey: "A", + wantValue: "=B", + }, + { + name: "", + env: "A=B=C=D", + wantKey: "A", + wantValue: "B=C=D", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, gotValue := SplitEnv(tt.env) + require.Equal(t, tt.wantKey, gotKey, "parseOSEnvs() gotKey") + require.Equal(t, tt.wantValue, gotValue, "parseOSEnvs() gotvalue") + }) + } +} diff --git a/env/sharedtestcases.go b/env/sharedtestcases.go new file mode 100644 index 00000000..280dd6a8 --- /dev/null +++ b/env/sharedtestcases.go @@ -0,0 +1,200 @@ +package env + +import ( + "github.com/bitrise-io/envman/models" +) + +// EnvmanSharedTestCases are test cases used as unit and integration tests. +var EnvmanSharedTestCases = []struct { + Name string + Envs []models.EnvironmentItemModel + Want []Command +}{ + { + Name: "empty env list", + Envs: []models.EnvironmentItemModel{}, + Want: []Command{}, + }, + { + Name: "unset env", + Envs: []models.EnvironmentItemModel{ + {"A": "B", "opts": map[string]interface{}{"unset": true}}, + }, + Want: []Command{ + {Action: UnsetAction, Variable: Variable{Key: "A"}}, + }, + }, + { + Name: "set env", + Envs: []models.EnvironmentItemModel{ + {"A": "B", "opts": map[string]interface{}{}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "B"}}, + }, + }, + { + Name: "set multiple envs", + Envs: []models.EnvironmentItemModel{ + {"A": "B", "opts": map[string]interface{}{}}, + {"B": "C", "opts": map[string]interface{}{}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "B"}}, + {Action: SetAction, Variable: Variable{Key: "B", Value: "C"}}, + }, + }, + { + Name: "set int env", + Envs: []models.EnvironmentItemModel{ + {"A": 12, "opts": map[string]interface{}{}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "12"}}, + }, + }, + { + Name: "skip env", + Envs: []models.EnvironmentItemModel{ + {"A": "B", "opts": map[string]interface{}{}}, + {"S": "", "opts": map[string]interface{}{"skip_if_empty": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "B"}}, + {Action: SkipAction, Variable: Variable{Key: "S"}}, + }, + }, + { + Name: "skip env, do not skip if not empty", + Envs: []models.EnvironmentItemModel{ + {"A": "B", "opts": map[string]interface{}{}}, + {"S": "T", "opts": map[string]interface{}{"skip_if_empty": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "B"}}, + {Action: SetAction, Variable: Variable{Key: "S", Value: "T"}}, + }, + }, + { + Name: "Env does only depend on envs declared before them", + Envs: []models.EnvironmentItemModel{ + {"simulator_device": "$simulator_major", "opts": map[string]interface{}{"is_expand": true}}, + {"simulator_major": "12", "opts": map[string]interface{}{"is_expand": false}}, + {"simulator_os_version": "$simulator_device", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "simulator_device", Value: ""}}, + {Action: SetAction, Variable: Variable{Key: "simulator_major", Value: "12"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: ""}}, + }, + }, + { + Name: "Env does only depend on envs declared before them (input order switched)", + Envs: []models.EnvironmentItemModel{ + {"simulator_device": "$simulator_major", "opts": map[string]interface{}{"is_expand": true}}, + {"simulator_os_version": "$simulator_device", "opts": map[string]interface{}{"is_sensitive": false}}, + {"simulator_major": "12", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "simulator_device", Value: ""}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: ""}}, + {Action: SetAction, Variable: Variable{Key: "simulator_major", Value: "12"}}, + }, + }, + { + Name: "Env does only depend on envs declared before them, envs in a loop", + Envs: []models.EnvironmentItemModel{ + {"A": "$C", "opts": map[string]interface{}{"is_expand": true}}, + {"B": "$A", "opts": map[string]interface{}{"is_expand": true}}, + {"C": "$B", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: ""}}, + {Action: SetAction, Variable: Variable{Key: "B", Value: ""}}, + {Action: SetAction, Variable: Variable{Key: "C", Value: ""}}, + }, + }, + { + Name: "Do not expand env if is_expand is false", + Envs: []models.EnvironmentItemModel{ + {"SIMULATOR_OS_VERSION": "13.3", "opts": map[string]interface{}{"is_expand": true}}, + {"simulator_os_version": "$SIMULATOR_OS_VERSION", "opts": map[string]interface{}{"is_expand": false}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_VERSION", Value: "13.3"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: "$SIMULATOR_OS_VERSION"}}, + }, + }, + { + Name: "Expand env, self reference", + Envs: []models.EnvironmentItemModel{ + {"SIMULATOR_OS_VERSION": "$SIMULATOR_OS_VERSION", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_VERSION", Value: ""}}, + }, + }, + { + Name: "Expand env, input contains env var", + Envs: []models.EnvironmentItemModel{ + {"SIMULATOR_OS_VERSION": "13.3", "opts": map[string]interface{}{"is_expand": false}}, + {"simulator_os_version": "$SIMULATOR_OS_VERSION", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_VERSION", Value: "13.3"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: "13.3"}}, + }, + }, + { + Name: "Multi level env var expansion", + Envs: []models.EnvironmentItemModel{ + {"A": "1", "opts": map[string]interface{}{"is_expand": true}}, + {"B": "$A", "opts": map[string]interface{}{"is_expand": true}}, + {"C": "prefix $B", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "A", Value: "1"}}, + {Action: SetAction, Variable: Variable{Key: "B", Value: "1"}}, + {Action: SetAction, Variable: Variable{Key: "C", Value: "prefix 1"}}, + }, + }, + { + Name: "Multi level env var expansion 2", + Envs: []models.EnvironmentItemModel{ + {"SIMULATOR_OS_MAJOR_VERSION": "13", "opts": map[string]interface{}{"is_expand": true}}, + {"SIMULATOR_OS_MINOR_VERSION": "3", "opts": map[string]interface{}{"is_expand": true}}, + {"SIMULATOR_OS_VERSION": "$SIMULATOR_OS_MAJOR_VERSION.$SIMULATOR_OS_MINOR_VERSION", "opts": map[string]interface{}{"is_expand": true}}, + {"simulator_os_version": "$SIMULATOR_OS_VERSION", "opts": map[string]interface{}{"is_expand": true}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_MAJOR_VERSION", Value: "13"}}, + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_MINOR_VERSION", Value: "3"}}, + {Action: SetAction, Variable: Variable{Key: "SIMULATOR_OS_VERSION", Value: "13.3"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: "13.3"}}, + }, + }, + { + Name: "Env expand, duplicate env declarations", + Envs: []models.EnvironmentItemModel{ + {"simulator_os_version": "12.1", "opts": map[string]interface{}{}}, + {"simulator_device": "iPhone 8 ($simulator_os_version)", "opts": map[string]interface{}{"is_expand": "true"}}, + {"simulator_os_version": "13.3", "opts": map[string]interface{}{}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: "12.1"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_device", Value: "iPhone 8 (12.1)"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_os_version", Value: "13.3"}}, + }, + }, + { + Name: "is_sensitive property is not affecting input expansion", + Envs: []models.EnvironmentItemModel{ + {"SECRET_ENV": "top secret", "opts": map[string]interface{}{"is_sensitive": true}}, + {"simulator_device": "iPhone $SECRET_ENV", "opts": map[string]interface{}{"is_expand": true, "is_sensitive": false}}, + }, + Want: []Command{ + {Action: SetAction, Variable: Variable{Key: "SECRET_ENV", Value: "top secret"}}, + {Action: SetAction, Variable: Variable{Key: "simulator_device", Value: "iPhone top secret"}}, + }, + }, +}