diff --git a/cmd/devbox/run.go b/cmd/devbox/run.go index 8f6ace0..474e296 100644 --- a/cmd/devbox/run.go +++ b/cmd/devbox/run.go @@ -12,13 +12,17 @@ import ( ) func init() { + root.AddCommand(newRunCmd()) +} + +func newRunCmd() *cobra.Command { var noTty bool cmd := &cobra.Command{ Use: "run ", 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) @@ -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) } @@ -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 { diff --git a/cmd/devbox/run_test.go b/cmd/devbox/run_test.go new file mode 100644 index 0000000..2b9cd5e --- /dev/null +++ b/cmd/devbox/run_test.go @@ -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) + } + }) + } +} diff --git a/docs/run.md b/docs/run.md index 467c683..e66c094 100644 --- a/docs/run.md +++ b/docs/run.md @@ -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 ```