Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions cmd/devbox/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import (
)

func init() {
root.AddCommand(newRunCmd())
}

func newRunCmd() *cobra.Command {
var noTty bool

cmd := &cobra.Command{
Use: "run <scenario>",
Short: "Run scenario defined in devbox project",
Long: "You can pass additional arguments to the scenario",
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: validArgsWrapper(
func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
p, err := mgr.AutodetectProject(ctx, projectName)
Expand All @@ -40,14 +44,9 @@ func init() {
return fmt.Errorf("failed to detect project: %w", err)
}

command := args[0]
if len(args) > 1 {
args = args[1:]
} else {
args = []string{}
}
scenario, passthrough := splitScenarioArgs(args)

if err := runRun(ctx, p, command, args, noTty); err != nil {
if err := runRun(ctx, p, scenario, passthrough, noTty); err != nil {
return fmt.Errorf("failed to run scenario: %w", err)
}

Expand All @@ -58,7 +57,21 @@ func init() {
cmd.Flags().BoolVarP(&noTty, "no-tty", "t", false, "Do not allocate a pseudo-TTY")
cmd.Flags().SetInterspersed(false)

root.AddCommand(cmd)
return cmd
}

// splitScenarioArgs returns the scenario name and its forwarded args, consuming one leading "--".
func splitScenarioArgs(args []string) (scenario string, passthrough []string) {
scenario = args[0]
passthrough = args[1:]

// Interspersing is off, so pflag never consumes the "--"; drop one leading
// separator so `run e2e -- --tag` and `run e2e --tag` forward the same args.
if len(passthrough) > 0 && passthrough[0] == "--" {
passthrough = passthrough[1:]
}

return scenario, passthrough
}

func runRun(ctx context.Context, p *project.Project, command string, args []string, noTtyFlag bool) error {
Expand Down
100 changes: 100 additions & 0 deletions cmd/devbox/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"slices"
"testing"
)

func TestSplitScenarioArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantScenario string
wantPassthrough []string
}{
{
name: "flag only",
args: []string{"e2e", "--tag", "foo"},
wantScenario: "e2e",
wantPassthrough: []string{"--tag", "foo"},
},
{
name: "separator stripped",
args: []string{"e2e", "--", "--tag", "foo"},
wantScenario: "e2e",
wantPassthrough: []string{"--tag", "foo"},
},
{name: "no args", args: []string{"e2e"}, wantScenario: "e2e", wantPassthrough: nil},
{name: "bare separator", args: []string{"e2e", "--"}, wantScenario: "e2e", wantPassthrough: nil},
{
name: "double separator keeps second",
args: []string{"e2e", "--", "--"},
wantScenario: "e2e",
wantPassthrough: []string{"--"},
},
{
name: "later separator untouched",
args: []string{"e2e", "--tag", "--", "foo"},
wantScenario: "e2e",
wantPassthrough: []string{"--tag", "--", "foo"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scenario, passthrough := splitScenarioArgs(tt.args)
if scenario != tt.wantScenario {
t.Errorf("scenario = %q, want %q", scenario, tt.wantScenario)
}
if !slices.Equal(passthrough, tt.wantPassthrough) {
t.Errorf("passthrough = %v, want %v", passthrough, tt.wantPassthrough)
}
})
}
}

// TestRunCmdArgsContract guards the real command's validator + SetInterspersed(false)
// against a revert to ExactArgs, which would break scenario argument pass-through.
func TestRunCmdArgsContract(t *testing.T) {
tests := []struct {
name string
argv []string
wantArgs []string
wantErr bool
}{
{name: "flag only", argv: []string{"e2e", "--tag", "foo"}, wantArgs: []string{"e2e", "--tag", "foo"}},
{
name: "explicit separator",
argv: []string{"e2e", "--", "--tag", "foo"},
wantArgs: []string{"e2e", "--", "--tag", "foo"},
},
{name: "scenario only", argv: []string{"e2e"}, wantArgs: []string{"e2e"}},
{name: "no scenario", argv: []string{}, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newRunCmd()
if err := cmd.ParseFlags(tt.argv); err != nil {
t.Fatalf("ParseFlags(%v) failed: %v", tt.argv, err)
}

got := cmd.Flags().Args()
err := cmd.ValidateArgs(got)

if tt.wantErr {
if err == nil {
t.Fatalf("ValidateArgs(%v) = nil, want error", got)
}
return
}

if err != nil {
t.Fatalf("ValidateArgs(%v) failed: %v", got, err)
}
if !slices.Equal(got, tt.wantArgs) {
t.Errorf("Flags().Args() = %v, want %v", got, tt.wantArgs)
}
})
}
}
3 changes: 3 additions & 0 deletions docs/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ devbox run test
# Run a test scenario with arguments
devbox run test --verbose --suite=api

# The same, using -- as an explicit separator (optional; it is consumed, not forwarded)
devbox run test -- --verbose --suite=api

# Run a scenario in a specific project
devbox --name project-name run test
```
Expand Down