From 501164a60ba788addc00d6e900ad22b47d884995 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:30:03 +0100 Subject: [PATCH 01/18] tests/e2e: add API testscript runner and instance reboot scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `testscript_api_test.go` (build tag `api`) with `json-setenv` and `wait-instance-state` custom commands - Each scenario owns its full resource lifecycle (create → test → delete) - First scenario: compute instance reboot - CI job runs on every push via `test-e2e-api` --- .github/workflows/main.yml | 23 ++ CHANGELOG.md | 1 + .../with-api/compute/instance_reboot.txtar | 29 +++ tests/e2e/testscript_api_test.go | 224 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 tests/e2e/scenarios/with-api/compute/instance_reboot.txtar create mode 100644 tests/e2e/testscript_api_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20c0d1f4f..c3f10a95a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,3 +39,26 @@ jobs: run: | cd tests/e2e go test -v + + test-e2e-api: + name: Run E2E (Testscript) Tests / With API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build binary + run: make build + + - name: Run E2E (Testscript) Tests / With API + env: + EXOSCALE_API_KEY: ${{ secrets.EXOSCALE_API_KEY }} + EXOSCALE_API_SECRET: ${{ secrets.EXOSCALE_API_SECRET }} + EXOSCALE_ZONE: ch-gva-2 + run: | + cd tests/e2e + go test -v -tags=api -timeout 30m -run TestScriptsAPI diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aabb6f37..8087722fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - test(testscript): add PTY infrastructure for testing interactive flows and commands #800 - test(testscript): add validation handling in PTY-based interactive flows #801 +- test(testscript): add API testscript runner with per-scenario resource lifecycle #804 ## 1.93.0 diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar new file mode 100644 index 000000000..651c2c968 --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -0,0 +1,29 @@ +# Test: exo compute instance reboot +# Full lifecycle: create a dedicated instance, reboot it, then delete it. +# TEST_ZONE and TEST_RUN_ID are injected by the API test runner. + +# Create a fresh instance for this test; testscript captures stdout automatically +exec exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 + +# Extract the instance ID into INSTANCE_ID env var from the captured stdout +json-setenv INSTANCE_ID id stdout + +# Wait for the instance to be fully running before rebooting +wait-instance-state $TEST_ZONE $INSTANCE_ID running 300 + +# Verify it is running +exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID +stdout '"state"' + +# Reboot the instance +exec exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID + +# Wait for it to return to running state after reboot +wait-instance-state $TEST_ZONE $INSTANCE_ID running 300 + +# Confirm final state is running +exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID +stdout '"state"' + +# Teardown: delete the instance +exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_ID diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go new file mode 100644 index 000000000..b4845c40b --- /dev/null +++ b/tests/e2e/testscript_api_test.go @@ -0,0 +1,224 @@ +//go:build api + +package e2e_test + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/rogpeppe/go-internal/testscript" +) + +// APITestSuite holds per-run metadata shared across all scenarios. +// Each scenario manages the lifecycle of its own resources. +type APITestSuite struct { + Zone string + RunID string +} + +// TestScriptsAPI runs testscript scenarios that require real API access. +// Run with: go test -v -tags=api -timeout 30m +// +// Required environment variables: +// +// EXOSCALE_API_KEY - Exoscale API key +// EXOSCALE_API_SECRET - Exoscale API secret +// EXOSCALE_ZONE - Zone to run tests in (default: ch-gva-2) +func TestScriptsAPI(t *testing.T) { + if os.Getenv("EXOSCALE_API_KEY") == "" || os.Getenv("EXOSCALE_API_SECRET") == "" { + t.Skip("Skipping API tests: EXOSCALE_API_KEY and EXOSCALE_API_SECRET must be set") + } + + zone := os.Getenv("EXOSCALE_ZONE") + if zone == "" { + zone = "ch-gva-2" + } + + runID := fmt.Sprintf("e2e-%d-%s", time.Now().Unix(), randString(6)) + t.Logf("API test run ID: %s (zone: %s)", runID, zone) + + suite := &APITestSuite{ + Zone: zone, + RunID: runID, + } + + // Run all scenarios under scenarios/with-api/ + files, err := findTestScripts("scenarios/with-api") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Log("No API test scenarios found in scenarios/with-api/") + return + } + + testscript.Run(t, testscript.Params{ + Files: files, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "execpty": cmdExecPTY, + "json-setenv": cmdJSONSetenv, + "wait-instance-state": cmdWaitInstanceState, + }, + Setup: func(e *testscript.Env) error { + return setupAPITestEnv(e, suite) + }, + }) +} + +// setupAPITestEnv configures the testscript environment with API credentials +// and run metadata. Each scenario creates and deletes its own resources. +func setupAPITestEnv(e *testscript.Env, suite *APITestSuite) error { + // Isolate config directory + e.Setenv("XDG_CONFIG_HOME", e.WorkDir+"/.config") + e.Setenv("HOME", e.WorkDir) + + // API credentials + e.Setenv("EXOSCALE_API_KEY", os.Getenv("EXOSCALE_API_KEY")) + e.Setenv("EXOSCALE_API_SECRET", os.Getenv("EXOSCALE_API_SECRET")) + + // Zone and run metadata + e.Setenv("EXO_ZONE", suite.Zone) + e.Setenv("EXO_OUTPUT", "json") + e.Setenv("TEST_RUN_ID", suite.RunID) + e.Setenv("TEST_ZONE", suite.Zone) + + // Write a ready-to-use config file so scenarios don't need to run exo config add + configDir := e.WorkDir + "/.config/exoscale" + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + configContent := fmt.Sprintf(`defaultAccount = "e2e-test" + +[[accounts]] +name = "e2e-test" +key = "%s" +secret = "%s" +defaultZone = "%s" +`, + os.Getenv("EXOSCALE_API_KEY"), + os.Getenv("EXOSCALE_API_SECRET"), + suite.Zone, + ) + return os.WriteFile(configDir+"/exoscale.toml", []byte(configContent), 0600) +} + +// cmdJSONSetenv is a testscript custom command: +// +// json-setenv VARNAME FIELD FILE +// +// Reads FILE (relative to WorkDir), parses it as JSON, and sets the env var +// VARNAME to the string value of top-level FIELD. If the JSON is an array, +// the first element is used. +// +// Typical use after capturing exec output: +// +// exec exo --output-format json compute instance create ... > out.json +// json-setenv INSTANCE_ID id out.json +func cmdJSONSetenv(ts *testscript.TestScript, neg bool, args []string) { + if len(args) != 3 { + ts.Fatalf("usage: json-setenv VARNAME FIELD FILE") + } + varName, field, file := args[0], args[1], args[2] + content := ts.ReadFile(file) + val, err := parseJSONField(content, field) + if err != nil { + ts.Fatalf("json-setenv: %v", err) + } + ts.Setenv(varName, val) +} + +// cmdWaitInstanceState is a testscript custom command: +// +// wait-instance-state ZONE INSTANCE_ID TARGET_STATE [TIMEOUT_SECONDS] +// +// Polls `exo compute instance show` until the instance reaches TARGET_STATE +// or the timeout elapses (default: 300 seconds). Fails the test on timeout. +func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { + if len(args) < 3 || len(args) > 4 { + ts.Fatalf("usage: wait-instance-state ZONE INSTANCE_ID TARGET_STATE [TIMEOUT_SECONDS]") + } + zone, instanceID, targetState := args[0], args[1], args[2] + timeout := 300 * time.Second + if len(args) == 4 { + var secs int + if _, err := fmt.Sscan(args[3], &secs); err != nil { + ts.Fatalf("wait-instance-state: invalid timeout %q: %v", args[3], err) + } + timeout = time.Duration(secs) * time.Second + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + out, err := runCLI( + "--zone", zone, + "--output-format", "json", + "compute", "instance", "show", + instanceID, + ) + if err != nil { + ts.Logf("wait-instance-state: poll error (will retry): %v", err) + time.Sleep(10 * time.Second) + continue + } + state, err := parseJSONField(out, "state") + if err != nil { + time.Sleep(10 * time.Second) + continue + } + ts.Logf("wait-instance-state: %s → %s (want: %s)", instanceID, state, targetState) + if state == targetState { + return + } + time.Sleep(10 * time.Second) + } + ts.Fatalf("wait-instance-state: timed out waiting for instance %s to reach state %q", instanceID, targetState) +} + +// runCLI runs the exo binary with the given arguments and returns combined stdout+stderr. +func runCLI(args ...string) (string, error) { + cmd := exec.Command(exoBinary, args...) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// parseJSONField extracts a top-level string field from a JSON object. +func parseJSONField(jsonStr, field string) (string, error) { + // Handle JSON arrays by taking the first element + trimmed := strings.TrimSpace(jsonStr) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &arr); err != nil { + return "", err + } + if len(arr) == 0 { + return "", fmt.Errorf("empty JSON array") + } + trimmed = string(arr[0]) + } + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { + return "", fmt.Errorf("failed to parse JSON: %w", err) + } + val, ok := obj[field] + if !ok { + return "", fmt.Errorf("field %q not found in JSON", field) + } + return fmt.Sprintf("%v", val), nil +} + +func randString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + r := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, n) + for i := range b { + b[i] = letters[r.Intn(len(letters))] + } + return string(b) +} From acd2a88d2fcf416b4b9e09d41e27bc63667c2708 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:38:13 +0100 Subject: [PATCH 02/18] ci: move e2e testscript jobs to dedicated pull_request workflow --- .github/workflows/e2e.yml | 47 ++++++++++++++++++++++++++++++++++++++ .github/workflows/main.yml | 42 ---------------------------------- 2 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..4738e74d7 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + +jobs: + test-e2e-without-api: + name: Run E2E (Testscript) Tests / Without API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build binary + run: make build + + - name: Run E2E (Testscript) Tests / Without API + run: | + cd tests/e2e + go test -v + + test-e2e-with-api: + name: Run E2E (Testscript) Tests / With API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build binary + run: make build + + - name: Run E2E (Testscript) Tests / With API + env: + EXOSCALE_API_KEY: ${{ secrets.EXOSCALE_API_KEY }} + EXOSCALE_API_SECRET: ${{ secrets.EXOSCALE_API_SECRET }} + EXOSCALE_ZONE: ch-gva-2 + run: | + cd tests/e2e + go test -v -tags=api -timeout 30m -run TestScriptsAPI diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3f10a95a..a7fa93c10 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,45 +20,3 @@ jobs: fetch-depth: 0 - uses: ./.github/actions/build - - test-e2e: - name: Run E2E (Testscript) Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - - name: Build binary - run: make build - - - name: Run E2E (Testscript) Tests / Without API - run: | - cd tests/e2e - go test -v - - test-e2e-api: - name: Run E2E (Testscript) Tests / With API - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - - name: Build binary - run: make build - - - name: Run E2E (Testscript) Tests / With API - env: - EXOSCALE_API_KEY: ${{ secrets.EXOSCALE_API_KEY }} - EXOSCALE_API_SECRET: ${{ secrets.EXOSCALE_API_SECRET }} - EXOSCALE_ZONE: ch-gva-2 - run: | - cd tests/e2e - go test -v -tags=api -timeout 30m -run TestScriptsAPI From 31031ae1bd4927390e6c4f8e7ab1912cce2c5f9e Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:28:06 +0100 Subject: [PATCH 03/18] tests/e2e: make PTY signal tests deterministic with @wait: token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a ptyInput struct that carries an optional waitFor pattern alongside the byte sequence to write. When waitFor is set, the input goroutine in runInPTY polls the shared, mutex-protected PTY output buffer until the pattern appears (or a 10-second deadline expires) before writing — instead of relying on a blind 300 ms sleep. Switch the PTY output collector from bufio.Scanner line-reads to a raw []byte read loop so that partial-line prompts (e.g. "[+] API Key: ") are captured immediately, before the process receives any input. Introduce @wait: syntax in the execpty stdin file format. A @wait: line is consumed as metadata and attached to the very next input token, making "send Ctrl+C only once the API-key prompt is visible" easy to express without any timing magic. Apply the fix to the two scenarios that were previously racy: - add_cancel_with_ctrl_c.txtar (@wait:[+] API Key: before @ctrl+c) - add_cancel_with_ctrl_d.txtar (@wait:[+] API Key: before @ctrl+d) --- .../config/add/add_cancel_with_ctrl_c.txtar | 1 + .../config/add/add_cancel_with_ctrl_d.txtar | 1 + tests/e2e/testscript_test.go | 123 +++++++++++++----- 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index 61370647b..5d9ebadac 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar @@ -6,4 +6,5 @@ stderr 'Error: Operation Cancelled' -- inputs -- +@wait:[+] API Key: @ctrl+c diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar index 39017412c..4d6f3102e 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar @@ -5,4 +5,5 @@ execpty --stdin=inputs exo config add -- inputs -- +@wait:[+] API Key: @ctrl+d diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 6f38b4099..94b2a6fe5 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -1,7 +1,6 @@ package e2e_test import ( - "bufio" "fmt" "io" "os" @@ -10,6 +9,7 @@ import ( "regexp" "runtime" "strings" + "sync" "testing" "time" @@ -61,38 +61,85 @@ var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } +// ptyInput represents a single keystroke/sequence to be fed into a PTY process. +// If waitFor is non-empty, the input is held until that exact string appears +// somewhere in the accumulated PTY output (or a 10-second deadline expires), +// making test scenarios deterministic even on slow CI runners. +type ptyInput struct { + waitFor string // pattern to wait for in PTY output before writing + data []byte // bytes to write to the PTY +} + // runInPTY starts cmd inside a PTY, optionally feeds keystrokes via the -// inputs channel (each []byte is written with a fixed delay between writes), -// collects all PTY output with ANSI stripped, waits for the process to exit -// and returns the cleaned output. -func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan []byte) string { +// inputs channel, collects all PTY output with ANSI stripped, waits for the +// process to exit and returns the cleaned output. +// +// Each ptyInput can carry a waitFor pattern: when set, the byte sequence is +// not written until that string appears in the accumulated PTY output (or a +// 10-second timeout elapses). Inputs without a waitFor are still preceded by +// a short fixed delay so that fast-typing scenarios remain stable. +func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) string { ptm, err := pty.Start(cmd) ts.Check(err) + // mu protects rawOut, which is the ANSI-stripped PTY output accumulated so + // far. The input goroutine reads it to detect wait-for patterns; the output + // goroutine writes it as bytes arrive from the PTY. + var mu sync.Mutex + var rawOut strings.Builder + + // Output collector: read the PTY with a raw byte loop so that partial lines + // (e.g. prompts that do not end with '\n') are captured immediately. + outCh := make(chan string, 1) + go func() { + var cleaned strings.Builder + buf := make([]byte, 4096) + for { + n, rerr := ptm.Read(buf) + if n > 0 { + chunk := stripANSI(string(buf[:n])) + mu.Lock() + rawOut.WriteString(chunk) + mu.Unlock() + // Accumulate per-line trimmed text for the return value. + for _, line := range strings.Split(chunk, "\n") { + if t := strings.TrimSpace(line); t != "" { + cleaned.WriteString(t + "\n") + } + } + } + if rerr != nil { + break + } + } + outCh <- cleaned.String() + }() + if inputs != nil { go func() { - for b := range inputs { - time.Sleep(300 * time.Millisecond) - if _, werr := ptm.Write(b); werr != nil && werr != io.ErrClosedPipe { + for inp := range inputs { + if inp.waitFor != "" { + // Block until the expected string appears in PTY output. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + found := strings.Contains(rawOut.String(), inp.waitFor) + mu.Unlock() + if found { + break + } + time.Sleep(50 * time.Millisecond) + } + } else { + time.Sleep(300 * time.Millisecond) + } + if _, werr := ptm.Write(inp.data); werr != nil && werr != io.ErrClosedPipe { return } } }() } - outCh := make(chan string, 1) - go func() { - var sb strings.Builder - scanner := bufio.NewScanner(ptm) - for scanner.Scan() { - line := strings.TrimSpace(stripANSI(scanner.Text())) - if line != "" { - sb.WriteString(line + "\n") - } - } - outCh <- sb.String() - }() - _ = cmd.Wait() _ = ptm.Close() return <-outCh @@ -128,32 +175,46 @@ func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { } } - inputs := make(chan []byte, len(tokens)) + // pendingWait is set by an @wait: line; it is attached to the + // very next input token so that the write is delayed until the pattern is + // visible in the PTY output. + var pendingWait string + + inputs := make(chan ptyInput, len(tokens)) for _, token := range tokens { + if after, ok := strings.CutPrefix(token, "@wait:"); ok { + pendingWait = after + continue + } + + inp := ptyInput{waitFor: pendingWait} + pendingWait = "" + switch token { case "@down", "↓": - inputs <- []byte{'\x1b', '[', 'B'} + inp.data = []byte{'\x1b', '[', 'B'} case "@up", "↑": - inputs <- []byte{'\x1b', '[', 'A'} + inp.data = []byte{'\x1b', '[', 'A'} case "@right", "→": - inputs <- []byte{'\x1b', '[', 'C'} + inp.data = []byte{'\x1b', '[', 'C'} case "@left", "←": - inputs <- []byte{'\x1b', '[', 'D'} + inp.data = []byte{'\x1b', '[', 'D'} case "@enter", "↵": - inputs <- []byte{'\r'} + inp.data = []byte{'\r'} case "@ctrl+c", "^C": - inputs <- []byte{'\x03'} + inp.data = []byte{'\x03'} case "@ctrl+d", "^D": - inputs <- []byte{'\x04'} + inp.data = []byte{'\x04'} case "@escape", "⎋": - inputs <- []byte{'\x1b'} + inp.data = []byte{'\x1b'} default: text := token if strings.HasPrefix(token, `\`) { text = token[1:] // strip the escape prefix, treat rest as literal } - inputs <- []byte(text + "\r") + inp.data = []byte(text + "\r") } + inputs <- inp } close(inputs) From 13479c0baa696ce16ed1a76c79ca7026c73836d6 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:37:03 +0100 Subject: [PATCH 04/18] =?UTF-8?q?tests/e2e:=20eliminate=20hardcoded=20dela?= =?UTF-8?q?ys=20=E2=80=94=20annotate=20all=20PTY=20inputs=20with=20@wait:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that runInPTY can gate each input write on a specific string appearing in the PTY output, every scenario can declare exactly when each keystroke should be sent, making test execution fully event-driven with zero timing magic. Remove the 300 ms fallback sleep from the input goroutine in runInPTY. Each ptyInput now either carries a @wait: pattern (blocks until the expected prompt text appears in accumulated PTY output) or is sent immediately as part of a sequential interaction with a prompt already on screen. Annotate every multi-step scenario: add_cancel_during_secret — waits for [+] API Key: then [+] Secret Key: add_cancel_during_zone — waits for each of the three text prompts then Default zone add_interactive_basic — same three text prompts + Default zone add_interactive_duplicate_name — additionally waits for 'already exist' and '[?] Set [' add_interactive_empty_validation — waits for each validation error message before re-entering add_interactive_make_new_default — text prompts + Default zone + [?] Set [ add_interactive_second_account — same add_interactive_zone_navigation — text prompts + Default zone (multiple @down with no extra wait) config_cancel_during_menu — waits for 'Configured accounts' before navigating --- .../without-api/config/add/add_cancel_during_secret.txtar | 2 ++ .../without-api/config/add/add_cancel_during_zone.txtar | 4 ++++ .../without-api/config/add/add_interactive_basic.txtar | 4 ++++ .../config/add/add_interactive_duplicate_name.txtar | 6 ++++++ .../config/add/add_interactive_empty_validation.txtar | 7 +++++++ .../config/add/add_interactive_make_new_default.txtar | 5 +++++ .../config/add/add_interactive_second_account.txtar | 5 +++++ .../config/add/add_interactive_zone_navigation.txtar | 4 ++++ .../without-api/config/config_cancel_during_menu.txtar | 1 + tests/e2e/testscript_test.go | 2 -- 10 files changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar index 7f55f522e..c4f74c7c2 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar @@ -6,5 +6,7 @@ stderr 'Error: Operation Cancelled' -- inputs -- +@wait:[+] API Key: EXOtest123 +@wait:[+] Secret Key: @ctrl+c diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar index 305cc48cc..d134183fa 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar @@ -7,8 +7,12 @@ stderr 'Error: Operation Cancelled' -- inputs -- +@wait:[+] API Key: EXOtest123 +@wait:[+] Secret Key: secretTest456 +@wait:[+] Name: TestAccount +@wait:Default zone @down @ctrl+c diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar index 911cde575..fe709c866 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar @@ -20,8 +20,12 @@ exec exo config list stdout 'TestAccount\*' -- inputs -- +@wait:[+] API Key: EXOtest123key +@wait:[+] Secret Key: secretTestKey123 +@wait:[+] Name: TestAccount +@wait:Default zone @down @enter diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar index baad9d403..f4b9ae3e3 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar @@ -21,11 +21,17 @@ stdout 'UniqueAccount' stdout 'EXOunique999' -- inputs -- +@wait:[+] API Key: EXOunique999 +@wait:[+] Secret Key: secretUnique999 +@wait:[+] Name: ExistingAccount +@wait:already exist UniqueAccount +@wait:Default zone @enter +@wait:[?] Set [ n -- initial-config.toml -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar index 8905fee02..fb19fa110 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar @@ -18,11 +18,18 @@ stdout 'TestAccount' stdout 'EXOvalid123' -- inputs -- +@wait:[+] API Key: @enter +@wait:API Key cannot be empty EXOvalid123 +@wait:[+] Secret Key: @enter +@wait:Secret Key cannot be empty validSecret456 +@wait:[+] Name: @enter +@wait:Name cannot be empty TestAccount +@wait:Default zone @down @enter diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar index b445b0c5d..350ddfbf3 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar @@ -19,10 +19,15 @@ stdout 'NewDefault\*' ! stdout 'FirstAccount\*' -- inputs -- +@wait:[+] API Key: EXOnewdefault789 +@wait:[+] Secret Key: secretNew789 +@wait:[+] Name: NewDefault +@wait:Default zone @enter +@wait:[?] Set [ y -- initial-config.toml -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar index 82b86cbc2..fafdfee03 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar @@ -24,10 +24,15 @@ stdout 'SecondAccount' stdout 'EXOsecond456' -- inputs -- +@wait:[+] API Key: EXOsecond456 +@wait:[+] Secret Key: secretSecond456 +@wait:[+] Name: SecondAccount +@wait:Default zone @enter +@wait:[?] Set [ n -- initial-config.toml -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar index 0b3d4f3f3..4216844d3 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar @@ -20,9 +20,13 @@ stdout 'EXOzonetest111' # this test would verify the defaultZone field in the config. -- inputs -- +@wait:[+] API Key: EXOzonetest111 +@wait:[+] Secret Key: secretZone111 +@wait:[+] Name: TestZoneNav +@wait:Default zone @down @down @down diff --git a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar index 329011e67..6da428d57 100644 --- a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar +++ b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar @@ -20,5 +20,6 @@ defaultaccount = "test" defaultzone = "ch-gva-2" -- inputs -- +@wait:Configured accounts @down @ctrl+c diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 94b2a6fe5..cdffc3bad 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -130,8 +130,6 @@ func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) } time.Sleep(50 * time.Millisecond) } - } else { - time.Sleep(300 * time.Millisecond) } if _, werr := ptm.Write(inp.data); werr != nil && werr != io.ErrClosedPipe { return From 2f16d9f983d595f36d7834fe4d8d5e3b69200e6b Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:40:28 +0100 Subject: [PATCH 05/18] test(testscript): make PTY inputs deterministic via event-driven @wait: synchronisation #804 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8087722fb..598a3448f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - test(testscript): add PTY infrastructure for testing interactive flows and commands #800 - test(testscript): add validation handling in PTY-based interactive flows #801 - test(testscript): add API testscript runner with per-scenario resource lifecycle #804 +- test(testscript): make PTY inputs deterministic via event-driven @wait: synchronisation #804 ## 1.93.0 From 9ca3d9456ca3064f4ac221e49e0a0867f16dfb2b Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:53:02 +0100 Subject: [PATCH 06/18] ci: trigger build+unit-tests on pull_request events too The main.yml build job only fired on push events, so a CHANGELOG.md-only commit (skipped by paths-ignore) left the PR with no Build check on its current head. Adding pull_request with the same paths-ignore ensures the build always runs against the PR head commit. --- .github/workflows/main.yml | 4 ++++ tests/e2e/testscript_api_test.go | 37 +++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a7fa93c10..eb283e377 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,10 @@ on: - 'bucket/**' tags-ignore: - 'v*' # Don't run CI tests on release tags + pull_request: + paths-ignore: + - '**.md' + - 'bucket/**' jobs: build: diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index b4845c40b..5e0a61ce4 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -61,8 +61,8 @@ func TestScriptsAPI(t *testing.T) { testscript.Run(t, testscript.Params{ Files: files, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "execpty": cmdExecPTY, - "json-setenv": cmdJSONSetenv, + "execpty": cmdExecPTY, + "json-setenv": cmdJSONSetenv, "wait-instance-state": cmdWaitInstanceState, }, Setup: func(e *testscript.Env) error { @@ -153,9 +153,34 @@ func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { timeout = time.Duration(secs) * time.Second } + // Propagate the testscript-isolated environment so the poll command uses the + // same config directory and credentials as the rest of the scenario, not the + // real process environment. + // We filter the real env first: on Linux getenv() returns the first match, + // so simply appending overrides to os.Environ() would have no effect. + overrides := map[string]string{ + "HOME": ts.Getenv("HOME"), + "XDG_CONFIG_HOME": ts.Getenv("XDG_CONFIG_HOME"), + "EXOSCALE_API_KEY": ts.Getenv("EXOSCALE_API_KEY"), + "EXOSCALE_API_SECRET": ts.Getenv("EXOSCALE_API_SECRET"), + } + pollEnv := make([]string, 0, len(os.Environ())) + for _, kv := range os.Environ() { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + if _, overridden := overrides[key]; !overridden { + pollEnv = append(pollEnv, kv) + } + } + for k, v := range overrides { + pollEnv = append(pollEnv, k+"="+v) + } + deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - out, err := runCLI( + out, err := runCLIWithEnv(pollEnv, "--zone", zone, "--output-format", "json", "compute", "instance", "show", @@ -182,7 +207,13 @@ func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { // runCLI runs the exo binary with the given arguments and returns combined stdout+stderr. func runCLI(args ...string) (string, error) { + return runCLIWithEnv(nil, args...) +} + +// runCLIWithEnv runs the exo binary with an explicit environment (nil inherits the process env). +func runCLIWithEnv(env []string, args ...string) (string, error) { cmd := exec.Command(exoBinary, args...) + cmd.Env = env out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } From dd783ebe2fb20894fee062bcb9def2afe4857ce9 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:21:26 +0100 Subject: [PATCH 07/18] feat(compute): improve error message when instance not found in zone [sc-167368] Add findInstance helper in cmd/compute/instance/instance_find.go wrapping FindListInstancesResponseInstances. On a not-found error it returns a zone-aware message with a hint to use -z or list all instances: instance "foo" not found in zone ch-gva-2 Hint: use -z to specify a different zone, or run 'exo compute instance list' to see instances across all zones All 24 call sites across the instance subcommands are migrated. Adds e2e scenario tests/e2e/scenarios/with-api/compute/instance_not_found_error.txtar. --- CHANGELOG.md | 4 +++ cmd/compute/instance/instance_console_url.go | 2 +- cmd/compute/instance/instance_delete.go | 2 +- .../instance/instance_elastic_ip_attach.go | 4 +-- .../instance/instance_elastic_ip_detach.go | 2 +- cmd/compute/instance/instance_enable_tpm.go | 2 +- cmd/compute/instance/instance_find.go | 26 +++++++++++++++++++ .../instance_private_network_attach.go | 2 +- .../instance_private_network_detach.go | 2 +- .../instance_private_network_updateip.go | 2 +- cmd/compute/instance/instance_reboot.go | 2 +- cmd/compute/instance/instance_reset.go | 2 +- .../instance/instance_reset_password.go | 2 +- cmd/compute/instance/instance_resizedisk.go | 2 +- .../instance/instance_reveal_password.go | 2 +- cmd/compute/instance/instance_scale.go | 2 +- cmd/compute/instance/instance_scp.go | 2 +- .../instance/instance_security_group_add.go | 2 +- .../instance_security_group_remove.go | 2 +- cmd/compute/instance/instance_show.go | 2 +- .../instance/instance_snapshot_create.go | 2 +- .../instance/instance_snapshot_revert.go | 2 +- cmd/compute/instance/instance_ssh.go | 2 +- cmd/compute/instance/instance_start.go | 2 +- cmd/compute/instance/instance_stop.go | 2 +- cmd/compute/instance/instance_update.go | 2 +- .../compute/instance_not_found_error.txtar | 20 ++++++++++++++ 27 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 cmd/compute/instance/instance_find.go create mode 100644 tests/e2e/scenarios/with-api/compute/instance_not_found_error.txtar diff --git a/CHANGELOG.md b/CHANGELOG.md index 598a3448f..be70c0a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Features - sks: add `rotate-karpenter-credentials` command #797 + +### Improvements + +- compute instance: enrich "not found" error with the zone searched and a hint to use -z #805 - sks: add `active-nodepool-templates` command #797 ### Bug fixes diff --git a/cmd/compute/instance/instance_console_url.go b/cmd/compute/instance/instance_console_url.go index 35f0f234b..df0a726d1 100644 --- a/cmd/compute/instance/instance_console_url.go +++ b/cmd/compute/instance/instance_console_url.go @@ -60,7 +60,7 @@ func (c *instanceConsoleURLCmd) CmdRun(cmd *cobra.Command, _ []string) error { return err } - foundInstance, err := resp.FindListInstancesResponseInstances(c.Instance) + foundInstance, err := findInstance(resp, c.Instance, string(c.Zone)) if err != nil { return err } diff --git a/cmd/compute/instance/instance_delete.go b/cmd/compute/instance/instance_delete.go index 01cbbd680..2de9c94bc 100644 --- a/cmd/compute/instance/instance_delete.go +++ b/cmd/compute/instance/instance_delete.go @@ -54,7 +54,7 @@ func (c *instanceDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error { instanceToDelete := []v3.UUID{} for _, i := range c.Instances { - instance, err := instances.FindListInstancesResponseInstances(i) + instance, err := findInstance(instances, i, c.Zone) if err != nil { if !c.Force { return err diff --git a/cmd/compute/instance/instance_elastic_ip_attach.go b/cmd/compute/instance/instance_elastic_ip_attach.go index 06edf3c76..b0ffb6d0e 100644 --- a/cmd/compute/instance/instance_elastic_ip_attach.go +++ b/cmd/compute/instance/instance_elastic_ip_attach.go @@ -54,9 +54,9 @@ func (c *instanceEIPAttachCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instancesList.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instancesList, c.Instance, c.Zone) if err != nil { - return fmt.Errorf("error retrieving Instance: %w", err) + return err } elasticIPs, err := client.ListElasticIPS(ctx) diff --git a/cmd/compute/instance/instance_elastic_ip_detach.go b/cmd/compute/instance/instance_elastic_ip_detach.go index ce2493f36..234147d7a 100644 --- a/cmd/compute/instance/instance_elastic_ip_detach.go +++ b/cmd/compute/instance/instance_elastic_ip_detach.go @@ -54,7 +54,7 @@ func (c *instanceEIPDetachCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instancesList.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instancesList, c.Instance, c.Zone) if err != nil { return fmt.Errorf("error retrieving Instance: %w", err) } diff --git a/cmd/compute/instance/instance_enable_tpm.go b/cmd/compute/instance/instance_enable_tpm.go index 19cd71d2b..0f98f0a45 100644 --- a/cmd/compute/instance/instance_enable_tpm.go +++ b/cmd/compute/instance/instance_enable_tpm.go @@ -45,7 +45,7 @@ func (c *instanceEnableTPMCmd) CmdRun(_ *cobra.Command, _ []string) error { return err } - instance, err := resp.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(resp, c.Instance, string(c.Zone)) if err != nil { return err } diff --git a/cmd/compute/instance/instance_find.go b/cmd/compute/instance/instance_find.go new file mode 100644 index 000000000..39225448e --- /dev/null +++ b/cmd/compute/instance/instance_find.go @@ -0,0 +1,26 @@ +package instance + +import ( + "errors" + "fmt" + + v3 "github.com/exoscale/egoscale/v3" +) + +// findInstance looks up an instance by name or ID from a ListInstancesResponse +// and enriches the "not found" error with the zone that was searched, +// reminding the user to use -z to target a different zone. +func findInstance(resp *v3.ListInstancesResponse, nameOrID, zone string) (v3.ListInstancesResponseInstances, error) { + instance, err := resp.FindListInstancesResponseInstances(nameOrID) + if err != nil { + if errors.Is(err, v3.ErrNotFound) { + return v3.ListInstancesResponseInstances{}, fmt.Errorf( + "instance %q not found in zone %s\nHint: use -z to specify a different zone, or run 'exo compute instance list' to see instances across all zones", + nameOrID, + zone, + ) + } + return v3.ListInstancesResponseInstances{}, err + } + return instance, nil +} diff --git a/cmd/compute/instance/instance_private_network_attach.go b/cmd/compute/instance/instance_private_network_attach.go index 6bb4f95dd..d3794dce2 100644 --- a/cmd/compute/instance/instance_private_network_attach.go +++ b/cmd/compute/instance/instance_private_network_attach.go @@ -56,7 +56,7 @@ func (c *instancePrivnetAttachCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_private_network_detach.go b/cmd/compute/instance/instance_private_network_detach.go index 1961c3f4f..aa428410b 100644 --- a/cmd/compute/instance/instance_private_network_detach.go +++ b/cmd/compute/instance/instance_private_network_detach.go @@ -54,7 +54,7 @@ func (c *instancePrivnetDetachCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_private_network_updateip.go b/cmd/compute/instance/instance_private_network_updateip.go index 3a35d11b3..d6c01e03e 100644 --- a/cmd/compute/instance/instance_private_network_updateip.go +++ b/cmd/compute/instance/instance_private_network_updateip.go @@ -57,7 +57,7 @@ func (c *instancePrivnetUpdateIPCmd) CmdRun(_ *cobra.Command, _ []string) error if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_reboot.go b/cmd/compute/instance/instance_reboot.go index f3621df22..c20f053c3 100644 --- a/cmd/compute/instance/instance_reboot.go +++ b/cmd/compute/instance/instance_reboot.go @@ -44,7 +44,7 @@ func (c *instanceRebootCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_reset.go b/cmd/compute/instance/instance_reset.go index 741eb79b5..5543de80f 100644 --- a/cmd/compute/instance/instance_reset.go +++ b/cmd/compute/instance/instance_reset.go @@ -59,7 +59,7 @@ func (c *instanceResetCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_reset_password.go b/cmd/compute/instance/instance_reset_password.go index 4b651ed18..9ff1ab4d9 100644 --- a/cmd/compute/instance/instance_reset_password.go +++ b/cmd/compute/instance/instance_reset_password.go @@ -43,7 +43,7 @@ func (c *instanceResetPasswordCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_resizedisk.go b/cmd/compute/instance/instance_resizedisk.go index cc4de84d3..be3591017 100644 --- a/cmd/compute/instance/instance_resizedisk.go +++ b/cmd/compute/instance/instance_resizedisk.go @@ -52,7 +52,7 @@ func (c *instanceResizeDiskCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_reveal_password.go b/cmd/compute/instance/instance_reveal_password.go index 26f10f254..cd299b219 100644 --- a/cmd/compute/instance/instance_reveal_password.go +++ b/cmd/compute/instance/instance_reveal_password.go @@ -50,7 +50,7 @@ func (c *instanceRevealCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_scale.go b/cmd/compute/instance/instance_scale.go index ed57a37cd..910ebe274 100644 --- a/cmd/compute/instance/instance_scale.go +++ b/cmd/compute/instance/instance_scale.go @@ -55,7 +55,7 @@ func (c *instanceScaleCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_scp.go b/cmd/compute/instance/instance_scp.go index 8c2298629..85612efcb 100644 --- a/cmd/compute/instance/instance_scp.go +++ b/cmd/compute/instance/instance_scp.go @@ -105,7 +105,7 @@ func (c *instanceSCPCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_security_group_add.go b/cmd/compute/instance/instance_security_group_add.go index df858c996..022cc0161 100644 --- a/cmd/compute/instance/instance_security_group_add.go +++ b/cmd/compute/instance/instance_security_group_add.go @@ -56,7 +56,7 @@ func (c *instanceSGAddCmd) CmdRun(cmd *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_security_group_remove.go b/cmd/compute/instance/instance_security_group_remove.go index e6f0bea85..c71effed9 100644 --- a/cmd/compute/instance/instance_security_group_remove.go +++ b/cmd/compute/instance/instance_security_group_remove.go @@ -57,7 +57,7 @@ func (c *instanceSGRemoveCmd) CmdRun(cmd *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_show.go b/cmd/compute/instance/instance_show.go index c82bc20ad..b17776577 100644 --- a/cmd/compute/instance/instance_show.go +++ b/cmd/compute/instance/instance_show.go @@ -87,7 +87,7 @@ func (c *instanceShowCmd) CmdRun(cmd *cobra.Command, _ []string) error { return err } - foundInstance, err := resp.FindListInstancesResponseInstances(c.Instance) + foundInstance, err := findInstance(resp, c.Instance, string(c.Zone)) if err != nil { return err } diff --git a/cmd/compute/instance/instance_snapshot_create.go b/cmd/compute/instance/instance_snapshot_create.go index e0d027469..dfaf60958 100644 --- a/cmd/compute/instance/instance_snapshot_create.go +++ b/cmd/compute/instance/instance_snapshot_create.go @@ -52,7 +52,7 @@ func (c *instanceSnapshotCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_snapshot_revert.go b/cmd/compute/instance/instance_snapshot_revert.go index 75b58a273..f2b66d469 100644 --- a/cmd/compute/instance/instance_snapshot_revert.go +++ b/cmd/compute/instance/instance_snapshot_revert.go @@ -60,7 +60,7 @@ func (c *instanceSnapshotRevertCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_ssh.go b/cmd/compute/instance/instance_ssh.go index 55470b3d4..85762b1ab 100644 --- a/cmd/compute/instance/instance_ssh.go +++ b/cmd/compute/instance/instance_ssh.go @@ -92,7 +92,7 @@ func (c *instanceSSHCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_start.go b/cmd/compute/instance/instance_start.go index 0fab93c62..30e970801 100644 --- a/cmd/compute/instance/instance_start.go +++ b/cmd/compute/instance/instance_start.go @@ -45,7 +45,7 @@ func (c *instanceStartCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_stop.go b/cmd/compute/instance/instance_stop.go index d4c8e8bd7..76f114b5d 100644 --- a/cmd/compute/instance/instance_stop.go +++ b/cmd/compute/instance/instance_stop.go @@ -44,7 +44,7 @@ func (c *instanceStopCmd) CmdRun(_ *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/cmd/compute/instance/instance_update.go b/cmd/compute/instance/instance_update.go index c02db9552..b052651d5 100644 --- a/cmd/compute/instance/instance_update.go +++ b/cmd/compute/instance/instance_update.go @@ -61,7 +61,7 @@ func (c *instanceUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { if err != nil { return err } - instance, err := instances.FindListInstancesResponseInstances(c.Instance) + instance, err := findInstance(instances, c.Instance, c.Zone) if err != nil { return err } diff --git a/tests/e2e/scenarios/with-api/compute/instance_not_found_error.txtar b/tests/e2e/scenarios/with-api/compute/instance_not_found_error.txtar new file mode 100644 index 000000000..237dd9a86 --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/instance_not_found_error.txtar @@ -0,0 +1,20 @@ +# Test: exo compute instance returns enriched error when instance not found +# Verifies that when an instance lookup fails, the error message includes the zone +# that was searched and a hint to use -z. +# No resources are created so no teardown is required. +# TEST_ZONE is injected by the API test runner. + +# show: instance not found should mention zone and hint +! exec exo --output-format json compute instance show nonexistent-e2e-instance +stderr 'not found in zone' +stderr 'Hint: use -z' + +# reboot: same helper, same enriched error +! exec exo --output-format json compute instance reboot --force nonexistent-e2e-instance +stderr 'not found in zone' +stderr 'Hint: use -z' + +# stop: same helper, same enriched error +! exec exo --output-format json compute instance stop --force nonexistent-e2e-instance +stderr 'not found in zone' +stderr 'Hint: use -z' From 35b4b5e9867fbdcd50cdb87729cbb068c813a304 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:53:09 +0100 Subject: [PATCH 08/18] fix(nlb): check UpdateLoadBalancer error before waiting on operation [sc-156206] When the API rejects the update request (e.g. duplicate name), the returned operation is nil. The missing error guard before DecorateAsyncOperation caused a confusing "operation is nil" error instead of surfacing the real API message. Also adds an e2e testscript scenario covering description update, name rename, and the duplicate-name conflict path. --- CHANGELOG.md | 1 + cmd/compute/load_balancer/nlb_update.go | 3 ++ .../with-api/compute/instance_reboot.txtar | 8 +--- .../with-api/compute/nlb_update.txtar | 34 +++++++++++++++ tests/e2e/testscript_api_test.go | 43 +++++++------------ 5 files changed, 55 insertions(+), 34 deletions(-) create mode 100644 tests/e2e/scenarios/with-api/compute/nlb_update.txtar diff --git a/CHANGELOG.md b/CHANGELOG.md index be70c0a13..a779e73e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Bug fixes +- fix(nlb): API error swallowed on load-balancer update (e.g. duplicate name conflict reported as "operation is nil") #806 - fix(config): panic when used without a default account set #798 ### Documentation diff --git a/cmd/compute/load_balancer/nlb_update.go b/cmd/compute/load_balancer/nlb_update.go index 2791a9ef8..d721c499b 100644 --- a/cmd/compute/load_balancer/nlb_update.go +++ b/cmd/compute/load_balancer/nlb_update.go @@ -82,6 +82,9 @@ func (c *nlbUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { if updated { op, err := client.UpdateLoadBalancer(ctx, n.ID, nlbRequest) + if err != nil { + return err + } utils.DecorateAsyncOperation( fmt.Sprintf("Updating Network Load Balancer %q...", c.NetworkLoadBalancer), diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar index 651c2c968..d3bf39c80 100644 --- a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -9,9 +9,7 @@ exec exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e- json-setenv INSTANCE_ID id stdout # Wait for the instance to be fully running before rebooting -wait-instance-state $TEST_ZONE $INSTANCE_ID running 300 - -# Verify it is running +wait-instance-state $TEST_ZONE $INSTANCE_ID running exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID stdout '"state"' @@ -19,9 +17,7 @@ stdout '"state"' exec exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID # Wait for it to return to running state after reboot -wait-instance-state $TEST_ZONE $INSTANCE_ID running 300 - -# Confirm final state is running +wait-instance-state $TEST_ZONE $INSTANCE_ID running exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID stdout '"state"' diff --git a/tests/e2e/scenarios/with-api/compute/nlb_update.txtar b/tests/e2e/scenarios/with-api/compute/nlb_update.txtar new file mode 100644 index 000000000..cdc9240aa --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/nlb_update.txtar @@ -0,0 +1,34 @@ +# Test: exo compute load-balancer update handles API errors and succeeds on valid updates +# Full lifecycle: create two NLBs, update name and description, test conflict error, delete both. +# TEST_ZONE and TEST_RUN_ID are injected by the API test runner. + +# Setup: create the primary NLB +exec exo --output-format json compute load-balancer create ${TEST_RUN_ID}-nlb-a --zone ${TEST_ZONE} +stdout ${TEST_RUN_ID}-nlb-a + +# Setup: create a second NLB whose name will be used for the conflict test +exec exo --output-format json compute load-balancer create ${TEST_RUN_ID}-nlb-b --zone ${TEST_ZONE} +stdout ${TEST_RUN_ID}-nlb-b + +# Update description and verify it is reflected in show output +exec exo --output-format json compute load-balancer update ${TEST_RUN_ID}-nlb-a --description 'hello e2e' --zone ${TEST_ZONE} + +exec exo --output-format json compute load-balancer show ${TEST_RUN_ID}-nlb-a --zone ${TEST_ZONE} +stdout 'hello e2e' + +# Update name and verify the NLB is reachable under the new name +exec exo --output-format json compute load-balancer update ${TEST_RUN_ID}-nlb-a --name ${TEST_RUN_ID}-nlb-a-renamed --zone ${TEST_ZONE} + +exec exo --output-format json compute load-balancer show ${TEST_RUN_ID}-nlb-a-renamed --zone ${TEST_ZONE} +stdout ${TEST_RUN_ID}-nlb-a-renamed + +# Rename back so teardown can reference it by the original name +exec exo --output-format json compute load-balancer update ${TEST_RUN_ID}-nlb-a-renamed --name ${TEST_RUN_ID}-nlb-a --zone ${TEST_ZONE} + +# Conflict: renaming to an already-existing name must surface the API error, not "operation is nil" +! exec exo --output-format json compute load-balancer update ${TEST_RUN_ID}-nlb-a --name ${TEST_RUN_ID}-nlb-b --zone ${TEST_ZONE} +stderr 'already exists' + +# Teardown: delete both NLBs +exec exo compute load-balancer delete --force ${TEST_RUN_ID}-nlb-a --zone ${TEST_ZONE} +exec exo compute load-balancer delete --force ${TEST_RUN_ID}-nlb-b --zone ${TEST_ZONE} diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 5e0a61ce4..8e9e270dd 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -23,7 +23,7 @@ type APITestSuite struct { } // TestScriptsAPI runs testscript scenarios that require real API access. -// Run with: go test -v -tags=api -timeout 30m +// Run with: go test -v -tags=api -timeout 10m // // Required environment variables: // @@ -135,23 +135,15 @@ func cmdJSONSetenv(ts *testscript.TestScript, neg bool, args []string) { // cmdWaitInstanceState is a testscript custom command: // -// wait-instance-state ZONE INSTANCE_ID TARGET_STATE [TIMEOUT_SECONDS] +// wait-instance-state ZONE INSTANCE_ID TARGET_STATE // -// Polls `exo compute instance show` until the instance reaches TARGET_STATE -// or the timeout elapses (default: 300 seconds). Fails the test on timeout. +// Polls `exo compute instance show` every 10 seconds until the instance reaches +// TARGET_STATE. The overall deadline is the test binary timeout (-timeout flag). func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { - if len(args) < 3 || len(args) > 4 { - ts.Fatalf("usage: wait-instance-state ZONE INSTANCE_ID TARGET_STATE [TIMEOUT_SECONDS]") + if len(args) != 3 { + ts.Fatalf("usage: wait-instance-state ZONE INSTANCE_ID TARGET_STATE") } zone, instanceID, targetState := args[0], args[1], args[2] - timeout := 300 * time.Second - if len(args) == 4 { - var secs int - if _, err := fmt.Sscan(args[3], &secs); err != nil { - ts.Fatalf("wait-instance-state: invalid timeout %q: %v", args[3], err) - } - timeout = time.Duration(secs) * time.Second - } // Propagate the testscript-isolated environment so the poll command uses the // same config directory and credentials as the rest of the scenario, not the @@ -178,8 +170,7 @@ func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { pollEnv = append(pollEnv, k+"="+v) } - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { + for { out, err := runCLIWithEnv(pollEnv, "--zone", zone, "--output-format", "json", @@ -188,21 +179,17 @@ func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { ) if err != nil { ts.Logf("wait-instance-state: poll error (will retry): %v", err) - time.Sleep(10 * time.Second) - continue - } - state, err := parseJSONField(out, "state") - if err != nil { - time.Sleep(10 * time.Second) - continue - } - ts.Logf("wait-instance-state: %s → %s (want: %s)", instanceID, state, targetState) - if state == targetState { - return + } else if state, err := parseJSONField(out, "state"); err != nil { + ts.Logf("wait-instance-state: could not parse state (will retry): %v", err) + } else { + ts.Logf("wait-instance-state: %s → %s (want: %s)", instanceID, state, targetState) + if state == targetState { + return + } } + time.Sleep(10 * time.Second) } - ts.Fatalf("wait-instance-state: timed out waiting for instance %s to reach state %q", instanceID, targetState) } // runCLI runs the exo binary with the given arguments and returns combined stdout+stderr. From fbcf90bdab009f55917b0a9247757a0feefc0835 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:30:37 +0100 Subject: [PATCH 09/18] tests/e2e: redesign exec-wait with bracket-delimited segments and external selector Replace the -- delimiter and until=field:value option with bracket-delimited [ cmd1 ] [ cmd2 ] [ selector ] segments. The selector is any process that reads cmd2 stdout on stdin and exits 0 when the condition is satisfied (e.g. jq, grep). This makes exec-wait composable and not tied to JSON: any tool that follows Unix exit-code conventions works as a predicate. Also replace splitByDoubleDash with splitByBrackets. --- .../with-api/compute/instance_reboot.txtar | 23 +-- tests/e2e/testscript_api_test.go | 154 +++++++++++++----- 2 files changed, 122 insertions(+), 55 deletions(-) diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar index d3bf39c80..1a23f5548 100644 --- a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -2,24 +2,11 @@ # Full lifecycle: create a dedicated instance, reboot it, then delete it. # TEST_ZONE and TEST_RUN_ID are injected by the API test runner. -# Create a fresh instance for this test; testscript captures stdout automatically -exec exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 +# Create the instance (sets INSTANCE_ID) and wait until running. +exec-wait set=INSTANCE_ID:id [ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] [ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] [ jq -e '.state == "running"' ] -# Extract the instance ID into INSTANCE_ID env var from the captured stdout -json-setenv INSTANCE_ID id stdout +# Reboot the instance and wait until it returns to running state. +exec-wait [ exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ] [ exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ] [ jq -e '.state == "running"' ] -# Wait for the instance to be fully running before rebooting -wait-instance-state $TEST_ZONE $INSTANCE_ID running -exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID -stdout '"state"' - -# Reboot the instance -exec exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID - -# Wait for it to return to running state after reboot -wait-instance-state $TEST_ZONE $INSTANCE_ID running -exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID -stdout '"state"' - -# Teardown: delete the instance +# Teardown: delete the instance. exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_ID diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 8e9e270dd..1497505cb 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -3,6 +3,7 @@ package e2e_test import ( + "bytes" "encoding/json" "fmt" "math/rand" @@ -61,9 +62,9 @@ func TestScriptsAPI(t *testing.T) { testscript.Run(t, testscript.Params{ Files: files, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "execpty": cmdExecPTY, - "json-setenv": cmdJSONSetenv, - "wait-instance-state": cmdWaitInstanceState, + "execpty": cmdExecPTY, + "exec-wait": cmdExecWait, + "json-setenv": cmdJSONSetenv, }, Setup: func(e *testscript.Env) error { return setupAPITestEnv(e, suite) @@ -133,65 +134,144 @@ func cmdJSONSetenv(ts *testscript.TestScript, neg bool, args []string) { ts.Setenv(varName, val) } -// cmdWaitInstanceState is a testscript custom command: -// -// wait-instance-state ZONE INSTANCE_ID TARGET_STATE -// -// Polls `exo compute instance show` every 10 seconds until the instance reaches -// TARGET_STATE. The overall deadline is the test binary timeout (-timeout flag). -func cmdWaitInstanceState(ts *testscript.TestScript, neg bool, args []string) { - if len(args) != 3 { - ts.Fatalf("usage: wait-instance-state ZONE INSTANCE_ID TARGET_STATE") - } - zone, instanceID, targetState := args[0], args[1], args[2] - - // Propagate the testscript-isolated environment so the poll command uses the - // same config directory and credentials as the rest of the scenario, not the - // real process environment. - // We filter the real env first: on Linux getenv() returns the first match, - // so simply appending overrides to os.Environ() would have no effect. +// buildPollEnv returns a copy of the process environment with the +// testscript-isolated credentials and config directory substituted in, so that +// poll subprocesses use the same identity as the rest of the scenario. +func buildPollEnv(ts *testscript.TestScript) []string { overrides := map[string]string{ "HOME": ts.Getenv("HOME"), "XDG_CONFIG_HOME": ts.Getenv("XDG_CONFIG_HOME"), "EXOSCALE_API_KEY": ts.Getenv("EXOSCALE_API_KEY"), "EXOSCALE_API_SECRET": ts.Getenv("EXOSCALE_API_SECRET"), } - pollEnv := make([]string, 0, len(os.Environ())) + env := make([]string, 0, len(os.Environ())) for _, kv := range os.Environ() { key := kv if i := strings.IndexByte(kv, '='); i >= 0 { key = kv[:i] } if _, overridden := overrides[key]; !overridden { - pollEnv = append(pollEnv, kv) + env = append(env, kv) } } for k, v := range overrides { - pollEnv = append(pollEnv, k+"="+v) + env = append(env, k+"="+v) + } + return env +} + +// cmdExecWait is a testscript custom command: +// +// exec-wait [set=VARNAME:jsonfield ...] [ cmd1... ] [ cmd2... ] [ selector... ] +// +// Runs cmd1 once, optionally extracts JSON fields from its output into +// testscript env vars (set=) and builds {VARNAME} substitutions for cmd2. +// Then polls cmd2 every 10 seconds, piping its stdout into the selector process. +// The selector is any program that reads stdin and exits 0 when the condition +// is met (e.g. `jq -e '.state == "running"'`, `grep -q running`). +// Polling stops as soon as the selector exits 0. +// +// In cmd2 args, {VARNAME} tokens are replaced with values extracted by set= +// after cmd1 runs, allowing cmd2 to reference IDs not yet known at parse time. +func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { + leadingOpts, groups := splitByBrackets(args) + if len(groups) != 3 { + ts.Fatalf("usage: exec-wait [set=VARNAME:jsonfield ...] [ cmd1... ] [ cmd2... ] [ selector... ]") } + cmd1Args, cmd2Template, selectorArgs := groups[0], groups[1], groups[2] + + type setVar struct{ varName, jsonField string } + var setVars []setVar + for _, opt := range leadingOpts { + if !strings.HasPrefix(opt, "set=") { + ts.Fatalf("exec-wait: unknown option %q (only set= is allowed before first [)", opt) + } + kv := strings.TrimPrefix(opt, "set=") + i := strings.IndexByte(kv, ':') + if i < 0 { + ts.Fatalf("exec-wait: invalid set= option %q, expected set=VARNAME:jsonfield", opt) + } + setVars = append(setVars, setVar{kv[:i], kv[i+1:]}) + } + + pollEnv := buildPollEnv(ts) + + // Run cmd1 once. + out, err := runCLIWithEnv(pollEnv, cmd1Args...) + if err != nil { + ts.Fatalf("exec-wait: cmd1 failed: %v\noutput: %s", err, out) + } + + // Extract set= vars and build {PLACEHOLDER} → value map. + replacements := make(map[string]string, len(setVars)) + for _, sv := range setVars { + val, err := parseJSONField(out, sv.jsonField) + if err != nil { + ts.Fatalf("exec-wait: set=%s:%s: %v", sv.varName, sv.jsonField, err) + } + ts.Setenv(sv.varName, val) + replacements["{"+sv.varName+"}"] = val + } + + // Resolve {PLACEHOLDER} tokens in the cmd2 template. + cmd2 := make([]string, len(cmd2Template)) + for i, arg := range cmd2Template { + resolved := arg + for placeholder, val := range replacements { + resolved = strings.ReplaceAll(resolved, placeholder, val) + } + cmd2[i] = resolved + } + + // Poll: run cmd2, pipe its stdout into selector, stop when selector exits 0. for { - out, err := runCLIWithEnv(pollEnv, - "--zone", zone, - "--output-format", "json", - "compute", "instance", "show", - instanceID, - ) + cmd2Out, err := runCLIWithEnv(pollEnv, cmd2...) if err != nil { - ts.Logf("wait-instance-state: poll error (will retry): %v", err) - } else if state, err := parseJSONField(out, "state"); err != nil { - ts.Logf("wait-instance-state: could not parse state (will retry): %v", err) - } else { - ts.Logf("wait-instance-state: %s → %s (want: %s)", instanceID, state, targetState) - if state == targetState { - return - } + ts.Logf("exec-wait: cmd2 error (will retry): %v", err) + time.Sleep(10 * time.Second) + continue } + sel := exec.Command(selectorArgs[0], selectorArgs[1:]...) + sel.Stdin = bytes.NewBufferString(cmd2Out) + selOut, selErr := sel.CombinedOutput() + ts.Logf("exec-wait: selector output: %s", strings.TrimSpace(string(selOut))) + if selErr == nil { + return + } + ts.Logf("exec-wait: selector not satisfied (will retry): %v", selErr) time.Sleep(10 * time.Second) } } +// splitByBrackets splits args into a leading options slice and bracket-delimited +// groups. Each group is the content between a "[" and its matching "]". +// Leading args before the first "[" are returned separately as options. +func splitByBrackets(args []string) (opts []string, groups [][]string) { + i := 0 + for i < len(args) && args[i] != "[" { + opts = append(opts, args[i]) + i++ + } + for i < len(args) { + if args[i] != "[" { + break + } + i++ // skip "[" + var group []string + for i < len(args) && args[i] != "]" { + group = append(group, args[i]) + i++ + } + if i < len(args) { + i++ // skip "]" + } + groups = append(groups, group) + } + return opts, groups +} + // runCLI runs the exo binary with the given arguments and returns combined stdout+stderr. func runCLI(args ...string) (string, error) { return runCLIWithEnv(nil, args...) From 051d81e740a32421e4e048f22358201d765a44ef Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:11:13 +0100 Subject: [PATCH 10/18] tests/e2e: fix exec-wait to run cmd1/cmd2 as generic processes - Run cmd1 and cmd2 via exec.Command directly instead of runCLIWithEnv, so the exo binary is not double-prefixed when 'exo' appears in brackets - Prepend exoBinary's directory to PATH in buildPollEnv so that 'exo' in bracket commands resolves to the correct binary --- tests/e2e/testscript_api_test.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 1497505cb..0106f29a4 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -9,6 +9,7 @@ import ( "math/rand" "os" "os/exec" + "path/filepath" "strings" "testing" "time" @@ -137,6 +138,8 @@ func cmdJSONSetenv(ts *testscript.TestScript, neg bool, args []string) { // buildPollEnv returns a copy of the process environment with the // testscript-isolated credentials and config directory substituted in, so that // poll subprocesses use the same identity as the rest of the scenario. +// It also prepends the directory containing the exo binary to PATH so that +// commands in exec-wait brackets can reference "exo" by name. func buildPollEnv(ts *testscript.TestScript) []string { overrides := map[string]string{ "HOME": ts.Getenv("HOME"), @@ -157,6 +160,15 @@ func buildPollEnv(ts *testscript.TestScript) []string { for k, v := range overrides { env = append(env, k+"="+v) } + // Prepend the exo binary directory so "exo" resolves in exec-wait bracket commands. + exoDir := filepath.Dir(exoBinary) + for i, kv := range env { + if strings.HasPrefix(kv, "PATH=") { + env[i] = "PATH=" + exoDir + ":" + kv[5:] + return env + } + } + env = append(env, "PATH="+exoDir) return env } @@ -198,9 +210,12 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { pollEnv := buildPollEnv(ts) // Run cmd1 once. - out, err := runCLIWithEnv(pollEnv, cmd1Args...) - if err != nil { - ts.Fatalf("exec-wait: cmd1 failed: %v\noutput: %s", err, out) + c1 := exec.Command(cmd1Args[0], cmd1Args[1:]...) + c1.Env = pollEnv + c1Out, c1Err := c1.CombinedOutput() + out := strings.TrimSpace(string(c1Out)) + if c1Err != nil { + ts.Fatalf("exec-wait: cmd1 failed: %v\noutput: %s", c1Err, out) } // Extract set= vars and build {PLACEHOLDER} → value map. @@ -226,9 +241,12 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { // Poll: run cmd2, pipe its stdout into selector, stop when selector exits 0. for { - cmd2Out, err := runCLIWithEnv(pollEnv, cmd2...) - if err != nil { - ts.Logf("exec-wait: cmd2 error (will retry): %v", err) + c2 := exec.Command(cmd2[0], cmd2[1:]...) + c2.Env = pollEnv + c2Out, c2Err := c2.CombinedOutput() + cmd2Out := strings.TrimSpace(string(c2Out)) + if c2Err != nil { + ts.Logf("exec-wait: cmd2 error (will retry): %v", c2Err) time.Sleep(10 * time.Second) continue } From e597c1f892a086516e614fb96035d57682cd4e2b Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:18:40 +0100 Subject: [PATCH 11/18] tests/e2e: fix exec-wait to use stdout-only capture for cmd1/cmd2 CombinedOutput mixes spinner text (written to stderr by DecorateAsyncOperation) into stdout, breaking JSON parsing. Use cmd.Output() + separate stderr buffer so only clean JSON stdout is passed to set= extraction and the selector. --- tests/e2e/testscript_api_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 0106f29a4..e40f1246d 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -209,13 +209,15 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { pollEnv := buildPollEnv(ts) - // Run cmd1 once. + // Run cmd1 once, capturing stdout only (stderr may contain spinner text). c1 := exec.Command(cmd1Args[0], cmd1Args[1:]...) c1.Env = pollEnv - c1Out, c1Err := c1.CombinedOutput() - out := strings.TrimSpace(string(c1Out)) + var c1Stderr bytes.Buffer + c1.Stderr = &c1Stderr + c1Stdout, c1Err := c1.Output() + out := strings.TrimSpace(string(c1Stdout)) if c1Err != nil { - ts.Fatalf("exec-wait: cmd1 failed: %v\noutput: %s", c1Err, out) + ts.Fatalf("exec-wait: cmd1 failed: %v\nstderr: %s", c1Err, c1Stderr.String()) } // Extract set= vars and build {PLACEHOLDER} → value map. @@ -239,18 +241,20 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { cmd2[i] = resolved } - // Poll: run cmd2, pipe its stdout into selector, stop when selector exits 0. + // Poll: run cmd2 (stdout only), pipe into selector, stop when selector exits 0. for { c2 := exec.Command(cmd2[0], cmd2[1:]...) c2.Env = pollEnv - c2Out, c2Err := c2.CombinedOutput() - cmd2Out := strings.TrimSpace(string(c2Out)) + var c2Stderr bytes.Buffer + c2.Stderr = &c2Stderr + c2Stdout, c2Err := c2.Output() if c2Err != nil { - ts.Logf("exec-wait: cmd2 error (will retry): %v", c2Err) + ts.Logf("exec-wait: cmd2 error (will retry): %v\nstderr: %s", c2Err, c2Stderr.String()) time.Sleep(10 * time.Second) continue } + cmd2Out := strings.TrimSpace(string(c2Stdout)) sel := exec.Command(selectorArgs[0], selectorArgs[1:]...) sel.Stdin = bytes.NewBufferString(cmd2Out) selOut, selErr := sel.CombinedOutput() From c0ac0878dfd575e823c04cdf27da03b4f8863cf5 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:51:46 +0100 Subject: [PATCH 12/18] tests/e2e: rename execpty to exec-pty with bracket syntax Renames the execpty custom testscript command to exec-pty for consistency with exec-wait, and updates its syntax to use bracket delimiters for the command: exec-pty --stdin= [ [args...] ] This separates the --stdin option cleanly from the command being run, and makes exec-pty visually consistent with exec-wait. splitByBrackets is moved from testscript_api_test.go (build tag: api) to testscript_test.go (always compiled) so that cmdExecPTY can use it without requiring the api build tag. All 11 .txtar scenario files updated to the new syntax. --- .../config/add/add_cancel_during_secret.txtar | 2 +- .../config/add/add_cancel_during_zone.txtar | 2 +- .../config/add/add_cancel_with_ctrl_c.txtar | 2 +- .../config/add/add_cancel_with_ctrl_d.txtar | 2 +- .../config/add/add_interactive_basic.txtar | 2 +- .../add/add_interactive_duplicate_name.txtar | 2 +- .../add_interactive_empty_validation.txtar | 2 +- .../add_interactive_make_new_default.txtar | 2 +- .../add/add_interactive_second_account.txtar | 2 +- .../add/add_interactive_zone_navigation.txtar | 2 +- .../config/config_cancel_during_menu.txtar | 2 +- tests/e2e/testscript_api_test.go | 29 +-------- tests/e2e/testscript_local_test.go | 2 +- tests/e2e/testscript_test.go | 60 ++++++++++++++----- 14 files changed, 59 insertions(+), 54 deletions(-) diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar index c4f74c7c2..4bcb208d9 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar @@ -2,7 +2,7 @@ # User enters API key but cancels at secret prompt # Attempt to add account and cancel at secret prompt -! execpty --stdin=inputs exo config add +! exec-pty --stdin=inputs [ exo config add ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar index d134183fa..4cfedd2f2 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar @@ -3,7 +3,7 @@ # Attempt to add account and cancel at zone selection # Wait for zone menu to appear (let API call fail first), then cancel -! execpty --stdin=inputs exo config add +! exec-pty --stdin=inputs [ exo config add ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index 5d9ebadac..aca605cc9 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar @@ -2,7 +2,7 @@ # Ctrl+C should show error message and exit with code 130 # Attempt to add account and cancel with Ctrl+C -! execpty --stdin=inputs exo config add +! exec-pty --stdin=inputs [ exo config add ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar index 4d6f3102e..a955b4ff0 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar @@ -2,7 +2,7 @@ # Ctrl+D should exit gracefully with code 0 (not an error like Ctrl+C) # Attempt to add account and cancel with Ctrl+D - should exit gracefully -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] -- inputs -- @wait:[+] API Key: diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar index fe709c866..49e8456ec 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add with PTY support -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] stdout 'No Exoscale CLI configuration found' stdout '\[TestAccount\] as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar index f4b9ae3e3..c870372b0 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Try to add account with duplicate name, then provide unique name -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] stdout 'Name \[ExistingAccount\] already exist' stdout 'UniqueAccount' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar index fb19fa110..ec925d330 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add, attempting to submit empty values first -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] stdout 'API Key cannot be empty' stdout 'Secret Key cannot be empty' stdout 'Name cannot be empty' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar index 350ddfbf3..91cc4ee1d 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account and make it default (answer 'y' to prompt) -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] stdout 'Set \[NewDefault\] as default account' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar index fafdfee03..99b145265 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account interactively and decline to make it default -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] ! stdout 'No Exoscale CLI configuration found' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar index 4216844d3..a4fefded0 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Add account and navigate down to select ch-dk-2 (second zone in typical list) # Typical zone order: at-vie-1, at-vie-2, bg-sof-1, ch-dk-2, ch-gva-2... -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs [ exo config add ] stdout 'TestZoneNav' stdout 'as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar index 6da428d57..92bbb590c 100644 --- a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar +++ b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar @@ -7,7 +7,7 @@ mkdir $HOME/.config/exoscale cp test-config.toml $HOME/.config/exoscale/exoscale.toml # Open config menu and cancel -! execpty --stdin=inputs exo config +! exec-pty --stdin=inputs [ exo config ] stderr 'Error: Operation Cancelled' -- test-config.toml -- diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index e40f1246d..19d2118ba 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -63,7 +63,7 @@ func TestScriptsAPI(t *testing.T) { testscript.Run(t, testscript.Params{ Files: files, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "execpty": cmdExecPTY, + "exec-pty": cmdExecPTY, "exec-wait": cmdExecWait, "json-setenv": cmdJSONSetenv, }, @@ -267,33 +267,6 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { } } -// splitByBrackets splits args into a leading options slice and bracket-delimited -// groups. Each group is the content between a "[" and its matching "]". -// Leading args before the first "[" are returned separately as options. -func splitByBrackets(args []string) (opts []string, groups [][]string) { - i := 0 - for i < len(args) && args[i] != "[" { - opts = append(opts, args[i]) - i++ - } - for i < len(args) { - if args[i] != "[" { - break - } - i++ // skip "[" - var group []string - for i < len(args) && args[i] != "]" { - group = append(group, args[i]) - i++ - } - if i < len(args) { - i++ // skip "]" - } - groups = append(groups, group) - } - return opts, groups -} - // runCLI runs the exo binary with the given arguments and returns combined stdout+stderr. func runCLI(args ...string) (string, error) { return runCLIWithEnv(nil, args...) diff --git a/tests/e2e/testscript_local_test.go b/tests/e2e/testscript_local_test.go index 13d2dd0ae..48e7a89ee 100644 --- a/tests/e2e/testscript_local_test.go +++ b/tests/e2e/testscript_local_test.go @@ -20,7 +20,7 @@ func TestScriptsLocal(t *testing.T) { testscript.Run(t, testscript.Params{ Files: files, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "execpty": cmdExecPTY, + "exec-pty": cmdExecPTY, }, Setup: func(e *testscript.Env) error { return setupTestEnv(e, false) diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index cdffc3bad..5da789f38 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -144,26 +144,31 @@ func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) } // cmdExecPTY mirrors the built-in exec but runs the binary inside a PTY. -// The input file is named explicitly via --stdin=, removing any +// The command is specified inside brackets: exec-pty --stdin= [ [args...] ] +// The --stdin flag names a testscript file containing input tokens, removing any // ambiguity with arguments forwarded to the binary itself. func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { + opts, groups := splitByBrackets(args) + if len(groups) != 1 { + ts.Fatalf("exec-pty: usage: exec-pty --stdin= [ [args...] ]") + } + cmdArgs := groups[0] + if len(cmdArgs) == 0 { + ts.Fatalf("exec-pty: no binary specified") + } + var stdinFile string - rest := args - for i, a := range args { - var found bool - if stdinFile, found = strings.CutPrefix(a, "--stdin="); found { - rest = append(args[:i:i], args[i+1:]...) + for _, o := range opts { + if v, ok := strings.CutPrefix(o, "--stdin="); ok { + stdinFile = v break } } if stdinFile == "" { - ts.Fatalf("execpty: usage: execpty --stdin= [args...]") - } - if len(rest) == 0 { - ts.Fatalf("execpty: no binary specified") + ts.Fatalf("exec-pty: usage: exec-pty --stdin= [ [args...] ]") } - bin, err := exec.LookPath(rest[0]) + bin, err := exec.LookPath(cmdArgs[0]) ts.Check(err) var tokens []string @@ -216,7 +221,7 @@ func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { } close(inputs) - cmd := exec.Command(bin, rest[1:]...) + cmd := exec.Command(bin, cmdArgs[1:]...) cmd.Dir = ts.Getenv("WORK") envMap := make(map[string]string) @@ -242,14 +247,41 @@ func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { exitCode := cmd.ProcessState.ExitCode() if exitCode != 0 { if !neg { - ts.Fatalf("execpty %s: exit code %d\noutput:\n%s", rest[0], exitCode, out) + ts.Fatalf("exec-pty %s: exit code %d\noutput:\n%s", cmdArgs[0], exitCode, out) } _, _ = fmt.Fprint(ts.Stderr(), out) return } if neg { - ts.Fatalf("execpty %s: unexpectedly succeeded\noutput:\n%s", rest[0], out) + ts.Fatalf("exec-pty %s: unexpectedly succeeded\noutput:\n%s", cmdArgs[0], out) } _, _ = fmt.Fprint(ts.Stdout(), out) } + +// splitByBrackets splits args into a leading options slice and bracket-delimited +// groups. Each group is the content between a "[" and its matching "]". +// Leading args before the first "[" are returned separately as options. +func splitByBrackets(args []string) (opts []string, groups [][]string) { + i := 0 + for i < len(args) && args[i] != "[" { + opts = append(opts, args[i]) + i++ + } + for i < len(args) { + if args[i] != "[" { + break + } + i++ // skip "[" + var group []string + for i < len(args) && args[i] != "]" { + group = append(group, args[i]) + i++ + } + if i < len(args) { + i++ // skip "]" + } + groups = append(groups, group) + } + return opts, groups +} From b0d33132a761e1ed18ef292a46541ddb9d5e1255 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:03:03 +0100 Subject: [PATCH 13/18] tests/e2e: exec-pty: use bracket group for stdin file Replace --stdin= flag with a second bracket group: exec-pty [ [args...] ] [ ] This removes the last flag-style option from exec-pty, making the syntax fully consistent with exec-wait where everything is expressed as bracket groups. --- .../config/add/add_cancel_during_secret.txtar | 2 +- .../config/add/add_cancel_during_zone.txtar | 2 +- .../config/add/add_cancel_with_ctrl_c.txtar | 2 +- .../config/add/add_cancel_with_ctrl_d.txtar | 2 +- .../config/add/add_interactive_basic.txtar | 2 +- .../add/add_interactive_duplicate_name.txtar | 2 +- .../add_interactive_empty_validation.txtar | 2 +- .../add_interactive_make_new_default.txtar | 2 +- .../add/add_interactive_second_account.txtar | 2 +- .../add/add_interactive_zone_navigation.txtar | 2 +- .../config/config_cancel_during_menu.txtar | 2 +- tests/e2e/testscript_test.go | 25 +++++++------------ 12 files changed, 20 insertions(+), 27 deletions(-) diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar index 4bcb208d9..cb4d9f18c 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar @@ -2,7 +2,7 @@ # User enters API key but cancels at secret prompt # Attempt to add account and cancel at secret prompt -! exec-pty --stdin=inputs [ exo config add ] +! exec-pty [ exo config add ] [ inputs ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar index 4cfedd2f2..dd3e5c475 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar @@ -3,7 +3,7 @@ # Attempt to add account and cancel at zone selection # Wait for zone menu to appear (let API call fail first), then cancel -! exec-pty --stdin=inputs [ exo config add ] +! exec-pty [ exo config add ] [ inputs ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index aca605cc9..a27a74223 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar @@ -2,7 +2,7 @@ # Ctrl+C should show error message and exit with code 130 # Attempt to add account and cancel with Ctrl+C -! exec-pty --stdin=inputs [ exo config add ] +! exec-pty [ exo config add ] [ inputs ] stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar index a955b4ff0..11f1a5010 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar @@ -2,7 +2,7 @@ # Ctrl+D should exit gracefully with code 0 (not an error like Ctrl+C) # Attempt to add account and cancel with Ctrl+D - should exit gracefully -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] -- inputs -- @wait:[+] API Key: diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar index 49e8456ec..44c13d513 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add with PTY support -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] stdout 'No Exoscale CLI configuration found' stdout '\[TestAccount\] as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar index c870372b0..5a5fc61cd 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Try to add account with duplicate name, then provide unique name -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] stdout 'Name \[ExistingAccount\] already exist' stdout 'UniqueAccount' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar index ec925d330..f45d00e11 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add, attempting to submit empty values first -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] stdout 'API Key cannot be empty' stdout 'Secret Key cannot be empty' stdout 'Name cannot be empty' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar index 91cc4ee1d..edecabd7d 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account and make it default (answer 'y' to prompt) -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] stdout 'Set \[NewDefault\] as default account' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar index 99b145265..5f5eb5c4d 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account interactively and decline to make it default -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] ! stdout 'No Exoscale CLI configuration found' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar index a4fefded0..7a4b50b75 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Add account and navigate down to select ch-dk-2 (second zone in typical list) # Typical zone order: at-vie-1, at-vie-2, bg-sof-1, ch-dk-2, ch-gva-2... -exec-pty --stdin=inputs [ exo config add ] +exec-pty [ exo config add ] [ inputs ] stdout 'TestZoneNav' stdout 'as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar index 92bbb590c..50baefd23 100644 --- a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar +++ b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar @@ -7,7 +7,7 @@ mkdir $HOME/.config/exoscale cp test-config.toml $HOME/.config/exoscale/exoscale.toml # Open config menu and cancel -! exec-pty --stdin=inputs [ exo config ] +! exec-pty [ exo config ] [ inputs ] stderr 'Error: Operation Cancelled' -- test-config.toml -- diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 5da789f38..498efa68f 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -144,29 +144,22 @@ func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) } // cmdExecPTY mirrors the built-in exec but runs the binary inside a PTY. -// The command is specified inside brackets: exec-pty --stdin= [ [args...] ] -// The --stdin flag names a testscript file containing input tokens, removing any -// ambiguity with arguments forwarded to the binary itself. +// Usage: exec-pty [ [args...] ] [ ] +// The first bracket group is the command; the second names a testscript file +// containing input tokens, one per line. func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { - opts, groups := splitByBrackets(args) - if len(groups) != 1 { - ts.Fatalf("exec-pty: usage: exec-pty --stdin= [ [args...] ]") + _, groups := splitByBrackets(args) + if len(groups) != 2 { + ts.Fatalf("exec-pty: usage: exec-pty [ [args...] ] [ ]") } cmdArgs := groups[0] if len(cmdArgs) == 0 { ts.Fatalf("exec-pty: no binary specified") } - - var stdinFile string - for _, o := range opts { - if v, ok := strings.CutPrefix(o, "--stdin="); ok { - stdinFile = v - break - } - } - if stdinFile == "" { - ts.Fatalf("exec-pty: usage: exec-pty --stdin= [ [args...] ]") + if len(groups[1]) != 1 { + ts.Fatalf("exec-pty: stdin group must contain exactly one filename") } + stdinFile := groups[1][0] bin, err := exec.LookPath(cmdArgs[0]) ts.Check(err) From 1dab64ec14957d7bc59049fdecfbc73c51821cd9 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:06:04 +0100 Subject: [PATCH 14/18] tests/e2e: exec-wait: named bracket groups --action=[], --polling=[], --predicate=[] Replace positional bracket groups with named ones: exec-wait [set=...] --action=[ cmd... ] --polling=[ cmd... ] --predicate=[ cmd... ] The "=[" is attached to the flag name as a single token, making each group's purpose self-documenting. Adds splitByNamedBrackets() alongside the existing splitByBrackets() (still used by exec-pty). --- .../with-api/compute/instance_reboot.txtar | 4 +-- tests/e2e/testscript_api_test.go | 29 ++++++++++--------- tests/e2e/testscript_test.go | 27 +++++++++++++++++ 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar index 1a23f5548..e5d97f5b6 100644 --- a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -3,10 +3,10 @@ # TEST_ZONE and TEST_RUN_ID are injected by the API test runner. # Create the instance (sets INSTANCE_ID) and wait until running. -exec-wait set=INSTANCE_ID:id [ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] [ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] [ jq -e '.state == "running"' ] +exec-wait set=INSTANCE_ID:id --action=[ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] --predicate=[ jq -e '.state == "running"' ] # Reboot the instance and wait until it returns to running state. -exec-wait [ exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ] [ exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ] [ jq -e '.state == "running"' ] +exec-wait --action=[ exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ] --predicate=[ jq -e '.state == "running"' ] # Teardown: delete the instance. exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_ID diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 19d2118ba..85db99878 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -174,30 +174,31 @@ func buildPollEnv(ts *testscript.TestScript) []string { // cmdExecWait is a testscript custom command: // -// exec-wait [set=VARNAME:jsonfield ...] [ cmd1... ] [ cmd2... ] [ selector... ] +// exec-wait [set=VARNAME:jsonfield ...] --action=[ cmd... ] --polling=[ cmd... ] --predicate=[ cmd... ] // -// Runs cmd1 once, optionally extracts JSON fields from its output into -// testscript env vars (set=) and builds {VARNAME} substitutions for cmd2. -// Then polls cmd2 every 10 seconds, piping its stdout into the selector process. -// The selector is any program that reads stdin and exits 0 when the condition -// is met (e.g. `jq -e '.state == "running"'`, `grep -q running`). -// Polling stops as soon as the selector exits 0. +// Runs the action once, optionally extracts JSON fields from its stdout into +// testscript env vars (set=) and builds {VARNAME} substitutions for the polling +// command. Then polls every 10 seconds, piping polling stdout into the predicate +// process. The predicate is any program that reads stdin and exits 0 when the +// condition is met (e.g. `jq -e '.state == "running"'`, `grep -q running`). // -// In cmd2 args, {VARNAME} tokens are replaced with values extracted by set= -// after cmd1 runs, allowing cmd2 to reference IDs not yet known at parse time. +// In polling args, {VARNAME} tokens are replaced with values extracted by set= +// after the action runs, allowing polling to reference IDs not known at parse time. func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { - leadingOpts, groups := splitByBrackets(args) - if len(groups) != 3 { - ts.Fatalf("usage: exec-wait [set=VARNAME:jsonfield ...] [ cmd1... ] [ cmd2... ] [ selector... ]") + leadingOpts, groups := splitByNamedBrackets(args) + for _, name := range []string{"action", "polling", "predicate"} { + if _, ok := groups[name]; !ok { + ts.Fatalf("exec-wait: missing --%s=[ ... ] group", name) + } } - cmd1Args, cmd2Template, selectorArgs := groups[0], groups[1], groups[2] + cmd1Args, cmd2Template, selectorArgs := groups["action"], groups["polling"], groups["predicate"] type setVar struct{ varName, jsonField string } var setVars []setVar for _, opt := range leadingOpts { if !strings.HasPrefix(opt, "set=") { - ts.Fatalf("exec-wait: unknown option %q (only set= is allowed before first [)", opt) + ts.Fatalf("exec-wait: unknown option %q (only set= is allowed outside groups)", opt) } kv := strings.TrimPrefix(opt, "set=") i := strings.IndexByte(kv, ':') diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 498efa68f..4c1f9abad 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -252,6 +252,33 @@ func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { _, _ = fmt.Fprint(ts.Stdout(), out) } +// splitByNamedBrackets parses named bracket groups of the form --name=[ args... ]. +// The "=[" must be attached to the flag name as a single token (no spaces). +// Tokens that are not part of a named group are returned as opts. +func splitByNamedBrackets(args []string) (opts []string, groups map[string][]string) { + groups = make(map[string][]string) + i := 0 + for i < len(args) { + if strings.HasPrefix(args[i], "--") && strings.HasSuffix(args[i], "=[") { + name := args[i][2 : len(args[i])-2] // strip "--" prefix and "=[" suffix + i++ + var group []string + for i < len(args) && args[i] != "]" { + group = append(group, args[i]) + i++ + } + if i < len(args) { + i++ // skip "]" + } + groups[name] = group + } else { + opts = append(opts, args[i]) + i++ + } + } + return opts, groups +} + // splitByBrackets splits args into a leading options slice and bracket-delimited // groups. Each group is the content between a "[" and its matching "]". // Leading args before the first "[" are returned separately as options. From ba0ea293856327c76974da8687a77a8fa002120d Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:18:26 +0100 Subject: [PATCH 15/18] tests/e2e: exec-wait: rename set= to --set= for uniform option style --- .../e2e/scenarios/with-api/compute/instance_reboot.txtar | 2 +- tests/e2e/testscript_api_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar index e5d97f5b6..2417379b4 100644 --- a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -3,7 +3,7 @@ # TEST_ZONE and TEST_RUN_ID are injected by the API test runner. # Create the instance (sets INSTANCE_ID) and wait until running. -exec-wait set=INSTANCE_ID:id --action=[ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] --predicate=[ jq -e '.state == "running"' ] +exec-wait --set=INSTANCE_ID:id --action=[ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] --predicate=[ jq -e '.state == "running"' ] # Reboot the instance and wait until it returns to running state. exec-wait --action=[ exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ] --predicate=[ jq -e '.state == "running"' ] diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 85db99878..3623cf98f 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -174,7 +174,7 @@ func buildPollEnv(ts *testscript.TestScript) []string { // cmdExecWait is a testscript custom command: // -// exec-wait [set=VARNAME:jsonfield ...] --action=[ cmd... ] --polling=[ cmd... ] --predicate=[ cmd... ] +// exec-wait [--set=VARNAME:jsonfield ...] --action=[ cmd... ] --polling=[ cmd... ] --predicate=[ cmd... ] // // Runs the action once, optionally extracts JSON fields from its stdout into // testscript env vars (set=) and builds {VARNAME} substitutions for the polling @@ -197,10 +197,10 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { var setVars []setVar for _, opt := range leadingOpts { - if !strings.HasPrefix(opt, "set=") { - ts.Fatalf("exec-wait: unknown option %q (only set= is allowed outside groups)", opt) + if !strings.HasPrefix(opt, "--set=") { + ts.Fatalf("exec-wait: unknown option %q (only --set= is allowed outside groups)", opt) } - kv := strings.TrimPrefix(opt, "set=") + kv := strings.TrimPrefix(opt, "--set=") i := strings.IndexByte(kv, ':') if i < 0 { ts.Fatalf("exec-wait: invalid set= option %q, expected set=VARNAME:jsonfield", opt) From 44bb2510e0404948ade276515d97fe18357b4627 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:33:22 +0100 Subject: [PATCH 16/18] tests/e2e: switch group delimiters from [ ] to ( ) --- .../with-api/compute/instance_reboot.txtar | 4 +-- .../config/add/add_cancel_during_secret.txtar | 2 +- .../config/add/add_cancel_during_zone.txtar | 2 +- .../config/add/add_cancel_with_ctrl_c.txtar | 2 +- .../config/add/add_cancel_with_ctrl_d.txtar | 2 +- .../config/add/add_interactive_basic.txtar | 2 +- .../add/add_interactive_duplicate_name.txtar | 2 +- .../add_interactive_empty_validation.txtar | 2 +- .../add_interactive_make_new_default.txtar | 2 +- .../add/add_interactive_second_account.txtar | 2 +- .../add/add_interactive_zone_navigation.txtar | 2 +- .../config/config_cancel_during_menu.txtar | 2 +- tests/e2e/testscript_api_test.go | 4 +-- tests/e2e/testscript_test.go | 34 +++++++++---------- 14 files changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar index 2417379b4..41825e2aa 100644 --- a/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -3,10 +3,10 @@ # TEST_ZONE and TEST_RUN_ID are injected by the API test runner. # Create the instance (sets INSTANCE_ID) and wait until running. -exec-wait --set=INSTANCE_ID:id --action=[ exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ] --predicate=[ jq -e '.state == "running"' ] +exec-wait --set=INSTANCE_ID:id --action=( exo --zone $TEST_ZONE --output-format json compute instance create cli-e2e-reboot-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 ) --polling=( exo --zone $TEST_ZONE --output-format json compute instance show {INSTANCE_ID} ) --predicate=( jq -e '.state == "running"' ) # Reboot the instance and wait until it returns to running state. -exec-wait --action=[ exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ] --polling=[ exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ] --predicate=[ jq -e '.state == "running"' ] +exec-wait --action=( exo --zone $TEST_ZONE compute instance reboot --force $INSTANCE_ID ) --polling=( exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_ID ) --predicate=( jq -e '.state == "running"' ) # Teardown: delete the instance. exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_ID diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar index cb4d9f18c..b0cd813f8 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar @@ -2,7 +2,7 @@ # User enters API key but cancels at secret prompt # Attempt to add account and cancel at secret prompt -! exec-pty [ exo config add ] [ inputs ] +! exec-pty ( exo config add ) ( inputs ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar index dd3e5c475..25ed073f1 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar @@ -3,7 +3,7 @@ # Attempt to add account and cancel at zone selection # Wait for zone menu to appear (let API call fail first), then cancel -! exec-pty [ exo config add ] [ inputs ] +! exec-pty ( exo config add ) ( inputs ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index a27a74223..419428dd6 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar @@ -2,7 +2,7 @@ # Ctrl+C should show error message and exit with code 130 # Attempt to add account and cancel with Ctrl+C -! exec-pty [ exo config add ] [ inputs ] +! exec-pty ( exo config add ) ( inputs ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar index 11f1a5010..96714cbcb 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar @@ -2,7 +2,7 @@ # Ctrl+D should exit gracefully with code 0 (not an error like Ctrl+C) # Attempt to add account and cancel with Ctrl+D - should exit gracefully -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) -- inputs -- @wait:[+] API Key: diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar index 44c13d513..fac1ca370 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add with PTY support -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) stdout 'No Exoscale CLI configuration found' stdout '\[TestAccount\] as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar index 5a5fc61cd..f755c6c1d 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Try to add account with duplicate name, then provide unique name -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) stdout 'Name \[ExistingAccount\] already exist' stdout 'UniqueAccount' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar index f45d00e11..b4e703705 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add, attempting to submit empty values first -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) stdout 'API Key cannot be empty' stdout 'Secret Key cannot be empty' stdout 'Name cannot be empty' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar index edecabd7d..dec198834 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account and make it default (answer 'y' to prompt) -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) stdout 'Set \[NewDefault\] as default account' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar index 5f5eb5c4d..c5df4107d 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account interactively and decline to make it default -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) ! stdout 'No Exoscale CLI configuration found' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar index 7a4b50b75..34f051c48 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Add account and navigate down to select ch-dk-2 (second zone in typical list) # Typical zone order: at-vie-1, at-vie-2, bg-sof-1, ch-dk-2, ch-gva-2... -exec-pty [ exo config add ] [ inputs ] +exec-pty ( exo config add ) ( inputs ) stdout 'TestZoneNav' stdout 'as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar index 50baefd23..67b11a621 100644 --- a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar +++ b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar @@ -7,7 +7,7 @@ mkdir $HOME/.config/exoscale cp test-config.toml $HOME/.config/exoscale/exoscale.toml # Open config menu and cancel -! exec-pty [ exo config ] [ inputs ] +! exec-pty ( exo config ) ( inputs ) stderr 'Error: Operation Cancelled' -- test-config.toml -- diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 3623cf98f..0c0e5bcc0 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -174,7 +174,7 @@ func buildPollEnv(ts *testscript.TestScript) []string { // cmdExecWait is a testscript custom command: // -// exec-wait [--set=VARNAME:jsonfield ...] --action=[ cmd... ] --polling=[ cmd... ] --predicate=[ cmd... ] +// exec-wait [--set=VARNAME:jsonfield ...] --action=( cmd... ) --polling=( cmd... ) --predicate=( cmd... ) // // Runs the action once, optionally extracts JSON fields from its stdout into // testscript env vars (set=) and builds {VARNAME} substitutions for the polling @@ -188,7 +188,7 @@ func cmdExecWait(ts *testscript.TestScript, neg bool, args []string) { leadingOpts, groups := splitByNamedBrackets(args) for _, name := range []string{"action", "polling", "predicate"} { if _, ok := groups[name]; !ok { - ts.Fatalf("exec-wait: missing --%s=[ ... ] group", name) + ts.Fatalf("exec-wait: missing --%s=( ... ) group", name) } } cmd1Args, cmd2Template, selectorArgs := groups["action"], groups["polling"], groups["predicate"] diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 4c1f9abad..83ba7c55b 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -144,13 +144,13 @@ func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) } // cmdExecPTY mirrors the built-in exec but runs the binary inside a PTY. -// Usage: exec-pty [ [args...] ] [ ] -// The first bracket group is the command; the second names a testscript file +// Usage: exec-pty ( [args...] ) ( ) +// The first group is the command; the second names a testscript file // containing input tokens, one per line. func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { _, groups := splitByBrackets(args) if len(groups) != 2 { - ts.Fatalf("exec-pty: usage: exec-pty [ [args...] ] [ ]") + ts.Fatalf("exec-pty: usage: exec-pty ( [args...] ) ( )") } cmdArgs := groups[0] if len(cmdArgs) == 0 { @@ -252,23 +252,23 @@ func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { _, _ = fmt.Fprint(ts.Stdout(), out) } -// splitByNamedBrackets parses named bracket groups of the form --name=[ args... ]. -// The "=[" must be attached to the flag name as a single token (no spaces). +// splitByNamedBrackets parses named groups of the form --name=( args... ). +// The "=(" must be attached to the flag name as a single token (no spaces). // Tokens that are not part of a named group are returned as opts. func splitByNamedBrackets(args []string) (opts []string, groups map[string][]string) { groups = make(map[string][]string) i := 0 for i < len(args) { - if strings.HasPrefix(args[i], "--") && strings.HasSuffix(args[i], "=[") { - name := args[i][2 : len(args[i])-2] // strip "--" prefix and "=[" suffix + if strings.HasPrefix(args[i], "--") && strings.HasSuffix(args[i], "=(") { + name := args[i][2 : len(args[i])-2] // strip "--" prefix and "=(" suffix i++ var group []string - for i < len(args) && args[i] != "]" { + for i < len(args) && args[i] != ")" { group = append(group, args[i]) i++ } if i < len(args) { - i++ // skip "]" + i++ // skip ")" } groups[name] = group } else { @@ -279,27 +279,27 @@ func splitByNamedBrackets(args []string) (opts []string, groups map[string][]str return opts, groups } -// splitByBrackets splits args into a leading options slice and bracket-delimited -// groups. Each group is the content between a "[" and its matching "]". -// Leading args before the first "[" are returned separately as options. +// splitByBrackets splits args into a leading options slice and paren-delimited +// groups. Each group is the content between a "(" and its matching ")". +// Leading args before the first "(" are returned separately as options. func splitByBrackets(args []string) (opts []string, groups [][]string) { i := 0 - for i < len(args) && args[i] != "[" { + for i < len(args) && args[i] != "(" { opts = append(opts, args[i]) i++ } for i < len(args) { - if args[i] != "[" { + if args[i] != "(" { break } - i++ // skip "[" + i++ // skip "(" var group []string - for i < len(args) && args[i] != "]" { + for i < len(args) && args[i] != ")" { group = append(group, args[i]) i++ } if i < len(args) { - i++ // skip "]" + i++ // skip ")" } groups = append(groups, group) } From 7141345ec77eb93c094b04b497502c90e35f07d5 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:42:11 +0100 Subject: [PATCH 17/18] tests/e2e: exec-pty: restore --stdin= flag, one paren group for command exec-pty --stdin= ( [args...] ) The stdin file is an explicit named flag rather than a second positional group, keeping the command group as the sole ( ) block. --- .../config/add/add_cancel_during_secret.txtar | 2 +- .../config/add/add_cancel_during_zone.txtar | 2 +- .../config/add/add_cancel_with_ctrl_c.txtar | 2 +- .../config/add/add_cancel_with_ctrl_d.txtar | 2 +- .../config/add/add_interactive_basic.txtar | 2 +- .../add/add_interactive_duplicate_name.txtar | 2 +- .../add_interactive_empty_validation.txtar | 2 +- .../add_interactive_make_new_default.txtar | 2 +- .../add/add_interactive_second_account.txtar | 2 +- .../add/add_interactive_zone_navigation.txtar | 2 +- .../config/config_cancel_during_menu.txtar | 2 +- tests/e2e/testscript_test.go | 24 ++++++++++++------- 12 files changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar index b0cd813f8..38dfcb3ea 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_secret.txtar @@ -2,7 +2,7 @@ # User enters API key but cancels at secret prompt # Attempt to add account and cancel at secret prompt -! exec-pty ( exo config add ) ( inputs ) +! exec-pty --stdin=inputs ( exo config add ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar index 25ed073f1..1b3a6ee0b 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_during_zone.txtar @@ -3,7 +3,7 @@ # Attempt to add account and cancel at zone selection # Wait for zone menu to appear (let API call fail first), then cancel -! exec-pty ( exo config add ) ( inputs ) +! exec-pty --stdin=inputs ( exo config add ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index 419428dd6..531dc74a8 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar @@ -2,7 +2,7 @@ # Ctrl+C should show error message and exit with code 130 # Attempt to add account and cancel with Ctrl+C -! exec-pty ( exo config add ) ( inputs ) +! exec-pty --stdin=inputs ( exo config add ) stderr 'Error: Operation Cancelled' -- inputs -- diff --git a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar index 96714cbcb..012b3b066 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_d.txtar @@ -2,7 +2,7 @@ # Ctrl+D should exit gracefully with code 0 (not an error like Ctrl+C) # Attempt to add account and cancel with Ctrl+D - should exit gracefully -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) -- inputs -- @wait:[+] API Key: diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar index fac1ca370..ebf80a74e 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_basic.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add with PTY support -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) stdout 'No Exoscale CLI configuration found' stdout '\[TestAccount\] as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar index f755c6c1d..a9b4635c2 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_duplicate_name.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Try to add account with duplicate name, then provide unique name -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) stdout 'Name \[ExistingAccount\] already exist' stdout 'UniqueAccount' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar index b4e703705..ea0e269cd 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_empty_validation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Run interactive config add, attempting to submit empty values first -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) stdout 'API Key cannot be empty' stdout 'Secret Key cannot be empty' stdout 'Name cannot be empty' diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar index dec198834..99af14dad 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_make_new_default.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account and make it default (answer 'y' to prompt) -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) stdout 'Set \[NewDefault\] as default account' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar index c5df4107d..fa06ba8b6 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_second_account.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale cp initial-config.toml .config/exoscale/exoscale.toml # Add second account interactively and decline to make it default -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) ! stdout 'No Exoscale CLI configuration found' # Verify both accounts exist diff --git a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar index 34f051c48..cb531aa92 100644 --- a/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar +++ b/tests/e2e/scenarios/without-api/config/add/add_interactive_zone_navigation.txtar @@ -6,7 +6,7 @@ mkdir -p .config/exoscale # Add account and navigate down to select ch-dk-2 (second zone in typical list) # Typical zone order: at-vie-1, at-vie-2, bg-sof-1, ch-dk-2, ch-gva-2... -exec-pty ( exo config add ) ( inputs ) +exec-pty --stdin=inputs ( exo config add ) stdout 'TestZoneNav' stdout 'as default account \(first account\)' diff --git a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar index 67b11a621..116be901b 100644 --- a/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar +++ b/tests/e2e/scenarios/without-api/config/config_cancel_during_menu.txtar @@ -7,7 +7,7 @@ mkdir $HOME/.config/exoscale cp test-config.toml $HOME/.config/exoscale/exoscale.toml # Open config menu and cancel -! exec-pty ( exo config ) ( inputs ) +! exec-pty --stdin=inputs ( exo config ) stderr 'Error: Operation Cancelled' -- test-config.toml -- diff --git a/tests/e2e/testscript_test.go b/tests/e2e/testscript_test.go index 83ba7c55b..ccf5d2339 100644 --- a/tests/e2e/testscript_test.go +++ b/tests/e2e/testscript_test.go @@ -144,22 +144,28 @@ func runInPTY(ts *testscript.TestScript, cmd *exec.Cmd, inputs <-chan ptyInput) } // cmdExecPTY mirrors the built-in exec but runs the binary inside a PTY. -// Usage: exec-pty ( [args...] ) ( ) -// The first group is the command; the second names a testscript file -// containing input tokens, one per line. +// Usage: exec-pty --stdin= ( [args...] ) +// --stdin names a testscript file containing input tokens, one per line. func cmdExecPTY(ts *testscript.TestScript, neg bool, args []string) { - _, groups := splitByBrackets(args) - if len(groups) != 2 { - ts.Fatalf("exec-pty: usage: exec-pty ( [args...] ) ( )") + opts, groups := splitByBrackets(args) + if len(groups) != 1 { + ts.Fatalf("exec-pty: usage: exec-pty --stdin= ( [args...] )") } cmdArgs := groups[0] if len(cmdArgs) == 0 { ts.Fatalf("exec-pty: no binary specified") } - if len(groups[1]) != 1 { - ts.Fatalf("exec-pty: stdin group must contain exactly one filename") + + var stdinFile string + for _, o := range opts { + if v, ok := strings.CutPrefix(o, "--stdin="); ok { + stdinFile = v + break + } + } + if stdinFile == "" { + ts.Fatalf("exec-pty: usage: exec-pty --stdin= ( [args...] )") } - stdinFile := groups[1][0] bin, err := exec.LookPath(cmdArgs[0]) ts.Check(err) From a3bd916d3ba4bf0f85d9553bf0d8b5a1051cfe14 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:59:10 +0100 Subject: [PATCH 18/18] tests/e2e: drop config file from API test setup, use env vars only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing credentials to a config file on disk during API tests is unnecessary — the CLI reads EXOSCALE_API_KEY/EXOSCALE_API_SECRET directly from the environment and ignores config files when they are set. Removing the file also eliminates the risk of secrets appearing in leftover test artifacts. HOME/XDG_CONFIG_HOME isolation is kept so tests cannot accidentally read the developer's real config file. --- tests/e2e/testscript_api_test.go | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 0c0e5bcc0..682e38a40 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -75,12 +75,15 @@ func TestScriptsAPI(t *testing.T) { // setupAPITestEnv configures the testscript environment with API credentials // and run metadata. Each scenario creates and deletes its own resources. +// Credentials are passed via env vars (EXOSCALE_API_KEY / EXOSCALE_API_SECRET) +// so the CLI never reads or writes a config file during API tests, keeping +// secrets off disk. func setupAPITestEnv(e *testscript.Env, suite *APITestSuite) error { - // Isolate config directory + // Isolate HOME so no real config file is accidentally read. e.Setenv("XDG_CONFIG_HOME", e.WorkDir+"/.config") e.Setenv("HOME", e.WorkDir) - // API credentials + // API credentials — the CLI reads these directly, ignoring any config file. e.Setenv("EXOSCALE_API_KEY", os.Getenv("EXOSCALE_API_KEY")) e.Setenv("EXOSCALE_API_SECRET", os.Getenv("EXOSCALE_API_SECRET")) @@ -90,24 +93,7 @@ func setupAPITestEnv(e *testscript.Env, suite *APITestSuite) error { e.Setenv("TEST_RUN_ID", suite.RunID) e.Setenv("TEST_ZONE", suite.Zone) - // Write a ready-to-use config file so scenarios don't need to run exo config add - configDir := e.WorkDir + "/.config/exoscale" - if err := os.MkdirAll(configDir, 0755); err != nil { - return err - } - configContent := fmt.Sprintf(`defaultAccount = "e2e-test" - -[[accounts]] -name = "e2e-test" -key = "%s" -secret = "%s" -defaultZone = "%s" -`, - os.Getenv("EXOSCALE_API_KEY"), - os.Getenv("EXOSCALE_API_SECRET"), - suite.Zone, - ) - return os.WriteFile(configDir+"/exoscale.toml", []byte(configContent), 0600) + return nil } // cmdJSONSetenv is a testscript custom command: