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 20c0d1f4f..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: @@ -20,22 +24,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aabb6f37..a779e73e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,15 @@ ### 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 +- 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 @@ -19,6 +24,8 @@ - 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 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/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_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' 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..41825e2aa --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/instance_reboot.txtar @@ -0,0 +1,12 @@ +# 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 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"' ) + +# 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"' ) + +# Teardown: delete the instance. +exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_ID 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/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..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,9 +2,11 @@ # 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 -- +@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..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,12 +3,16 @@ # 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 -- +@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_cancel_with_ctrl_c.txtar b/tests/e2e/scenarios/without-api/config/add/add_cancel_with_ctrl_c.txtar index 61370647b..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,8 +2,9 @@ # 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 -- +@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..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,8 @@ # 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: @ctrl+d 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..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 -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\)' @@ -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..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 -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs ( exo config add ) stdout 'Name \[ExistingAccount\] already exist' stdout 'UniqueAccount' @@ -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..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 -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' @@ -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..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) -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs ( exo config add ) stdout 'Set \[NewDefault\] as default account' # Verify both accounts exist @@ -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..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 -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs ( exo config add ) ! stdout 'No Exoscale CLI configuration found' # Verify both accounts exist @@ -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..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... -execpty --stdin=inputs exo config add +exec-pty --stdin=inputs ( exo config add ) stdout 'TestZoneNav' stdout 'as default account \(first account\)' @@ -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..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 -! execpty --stdin=inputs exo config +! exec-pty --stdin=inputs ( exo config ) stderr 'Error: Operation Cancelled' -- test-config.toml -- @@ -20,5 +20,6 @@ defaultaccount = "test" defaultzone = "ch-gva-2" -- inputs -- +@wait:Configured accounts @down @ctrl+c diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go new file mode 100644 index 000000000..682e38a40 --- /dev/null +++ b/tests/e2e/testscript_api_test.go @@ -0,0 +1,304 @@ +//go:build api + +package e2e_test + +import ( + "bytes" + "encoding/json" + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "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 10m +// +// 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){ + "exec-pty": cmdExecPTY, + "exec-wait": cmdExecWait, + "json-setenv": cmdJSONSetenv, + }, + 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. +// 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 HOME so no real config file is accidentally read. + e.Setenv("XDG_CONFIG_HOME", e.WorkDir+"/.config") + e.Setenv("HOME", e.WorkDir) + + // 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")) + + // 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) + + return nil +} + +// 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) +} + +// 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"), + "XDG_CONFIG_HOME": ts.Getenv("XDG_CONFIG_HOME"), + "EXOSCALE_API_KEY": ts.Getenv("EXOSCALE_API_KEY"), + "EXOSCALE_API_SECRET": ts.Getenv("EXOSCALE_API_SECRET"), + } + 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 { + env = append(env, kv) + } + } + 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 +} + +// cmdExecWait is a testscript custom command: +// +// 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 +// 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 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 := 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["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 outside groups)", 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, capturing stdout only (stderr may contain spinner text). + c1 := exec.Command(cmd1Args[0], cmd1Args[1:]...) + c1.Env = pollEnv + 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\nstderr: %s", c1Err, c1Stderr.String()) + } + + // 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 (stdout only), pipe into selector, stop when selector exits 0. + for { + c2 := exec.Command(cmd2[0], cmd2[1:]...) + c2.Env = pollEnv + var c2Stderr bytes.Buffer + c2.Stderr = &c2Stderr + c2Stdout, c2Err := c2.Output() + if c2Err != nil { + 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() + 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) + } +} + +// 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 +} + +// 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) +} 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 6f38b4099..ccf5d2339 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,64 +61,113 @@ 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) + } + } + 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 } // cmdExecPTY mirrors the built-in exec but runs the binary inside a PTY. -// The input file is named explicitly via --stdin=, removing any -// ambiguity with arguments forwarded to the binary itself. +// 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) { + 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 @@ -128,36 +177,50 @@ 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) - cmd := exec.Command(bin, rest[1:]...) + cmd := exec.Command(bin, cmdArgs[1:]...) cmd.Dir = ts.Getenv("WORK") envMap := make(map[string]string) @@ -183,14 +246,68 @@ 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) } + +// 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 + 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 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] != "(" { + 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 +}