package cli import ( "bytes" "errors" "io" "os" "runtime" "strings" "testing" "time" "termtap.dev/internal/app" "termtap.dev/internal/model" "termtap.dev/internal/tui" ) func TestParseCommand(t *testing.T) { tests := []struct { name string args []string ok bool nameWant string argsWant []string addrWant string }{ {name: "too few args", args: []string{"tap"}, ok: false}, {name: "missing run token", args: []string{"tap", "oops", "--", "echo"}, ok: false}, {name: "missing separator", args: []string{"tap", "run", "echo"}, ok: false}, {name: "single command", args: []string{"tap", "run", "--", "echo"}, ok: true, nameWant: "echo", argsWant: []string{}, addrWant: "127.0.0.1:8080"}, {name: "command with args", args: []string{"tap", "run", "--", "curl", "-s", "https://example.com"}, ok: true, nameWant: "curl", argsWant: []string{"-s", "https://example.com"}, addrWant: "127.0.0.1:8080"}, {name: "custom port", args: []string{"tap", "run", "--port", "9090", "--", "echo"}, ok: true, nameWant: "echo", argsWant: []string{}, addrWant: "127.0.0.1:9090"}, {name: "port missing value", args: []string{"tap", "run", "--port", "--", "echo"}, ok: false}, {name: "port invalid", args: []string{"tap", "run", "--port", "wat", "--", "echo"}, ok: false}, {name: "port out of range", args: []string{"tap", "run", "--port", "70000", "--", "echo"}, ok: false}, {name: "unknown flag", args: []string{"tap", "run", "--wat", "--", "echo"}, ok: false}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { cmd, addr, ok := parseCommand(tt.args) if ok != tt.ok { t.Fatalf("ok = %v, want %v", ok, tt.ok) } if !tt.ok { return } if cmd.Name != tt.nameWant { t.Fatalf("cmd.Name = %q, want %q", cmd.Name, tt.nameWant) } if strings.Join(cmd.Args, "|") != strings.Join(tt.argsWant, "|") { t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, tt.argsWant) } if addr != tt.addrWant { t.Fatalf("addr = %q, want %q", addr, tt.addrWant) } }) } } func TestDisplayHelpWritesToStderr(t *testing.T) { _, stderr := captureOutput(t, func() { displayHelp() }) if !strings.Contains(stderr, "tap demo") || !strings.Contains(stderr, "tap cert") || !strings.Contains(stderr, "tap run [--port ] --") { t.Fatalf("stderr missing usage text: %q", stderr) } } func TestRun_InvalidCommandShowsHelp(t *testing.T) { _, stderr := captureOutput(t, func() { Run([]string{"tap", "wat"}) }) if !strings.Contains(stderr, "usage:") { t.Fatalf("stderr missing usage output: %q", stderr) } } func TestRun_RoutesCertCommand(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) stdout, _ := captureOutput(t, func() { Run([]string{"tap", "cert"}) }) if !strings.Contains(stdout, "Certificate path:") { t.Fatalf("stdout missing certificate path output: %q", stdout) } } func TestRun_RoutesDemoCommand(t *testing.T) { restore := installRunSeams(t) defer restore() called := installFatalSpy(t) seen := 0 runTUIFn = func(ch <-chan model.Event, _ tui.Controls) error { for i := 0; i < 3; i++ { select { case ev := <-ch: seen++ if seen == 1 && ev.Type != model.EventTypeSessionStarted { t.Fatalf("first demo event = %s, want %s", ev.Type, model.EventTypeSessionStarted) } default: return nil } } return nil } Run([]string{"tap", "demo"}) if *called { t.Fatal("fatalExit should not be called for demo command") } if seen == 0 { t.Fatal("expected demo event stream to be seeded") } } func TestRun_StartSessionFailureCallsFatalExit(t *testing.T) { restore := installRunSeams(t) defer restore() startSessionFn = func(model.Command, string) (*app.Session, error) { return nil, errors.New("boom") } called := installFatalSpy(t) Run([]string{"tap", "run", "--", "definitely-not-a-real-command"}) if !*called { t.Fatal("expected fatalExit to be called on StartSession failure") } } func TestRun_TUIFailureCallsFatalExit(t *testing.T) { restore := installRunSeams(t) defer restore() startSessionFn = func(model.Command, string) (*app.Session, error) { return &app.Session{Events: make(chan model.Event)}, nil } runTUIFn = func(<-chan model.Event, tui.Controls) error { return errors.New("tui failed") } called := installFatalSpy(t) Run([]string{"tap", "run", "--", "echo"}) if !*called { t.Fatal("expected fatalExit to be called on tui failure") } } func TestRun_SuccessPathDoesNotCallFatal(t *testing.T) { restore := installRunSeams(t) defer restore() startSessionFn = func(model.Command, string) (*app.Session, error) { return &app.Session{Events: make(chan model.Event)}, nil } runTUIFn = func(<-chan model.Event, tui.Controls) error { return nil } called := installFatalSpy(t) Run([]string{"tap", "run", "--", "echo"}) if *called { t.Fatal("fatalExit should not be called on success path") } } func TestRun_CustomPortPassedToSession(t *testing.T) { restore := installRunSeams(t) defer restore() var gotAddr string startSessionFn = func(_ model.Command, addr string) (*app.Session, error) { gotAddr = addr return &app.Session{Events: make(chan model.Event)}, nil } runTUIFn = func(<-chan model.Event, tui.Controls) error { return nil } Run([]string{"tap", "run", "--port", "9091", "--", "echo"}) if gotAddr != "127.0.0.1:9091" { t.Fatalf("session addr = %q, want %q", gotAddr, "127.0.0.1:9091") } } func TestRunCert_EnsureCAFailureCallsFatalExit(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("HOME", "") called := installFatalSpy(t) runCert() if !*called { t.Fatal("expected fatalExit to be called when EnsureCertificateAuthority fails") } } func TestRunCertOutputContract(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) origGOOS := currentGOOS t.Cleanup(func() { currentGOOS = origGOOS }) stdout, _ := captureOutput(t, func() { runCert() }) if !strings.Contains(stdout, "Certificate path:") { t.Fatalf("stdout missing certificate path line: %q", stdout) } if !strings.Contains(stdout, "local HTTPS interception CA") { t.Fatalf("stdout missing CA create/existing line: %q", stdout) } if !strings.Contains(stdout, "System trust store:") && !strings.Contains(stdout, "System trust check failed:") { t.Fatalf("stdout missing trust check line: %q", stdout) } if runtime.GOOS == "linux" { if !strings.Contains(stdout, "Trust instructions (Linux):") { t.Fatalf("stdout missing linux trust instructions: %q", stdout) } } } func TestRunCert_WindowsHint(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) origGOOS := currentGOOS t.Cleanup(func() { currentGOOS = origGOOS }) currentGOOS = "windows" stdout, _ := captureOutput(t, func() { runCert() }) if !strings.Contains(stdout, "Windows Trusted Root Certification Authorities") { t.Fatalf("stdout missing windows trust hint: %q", stdout) } if !strings.Contains(stdout, "--ssl-no-revoke") { t.Fatalf("stdout missing windows curl hint: %q", stdout) } } func TestRunCert_DarwinHint(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) origGOOS := currentGOOS t.Cleanup(func() { currentGOOS = origGOOS }) currentGOOS = "darwin" stdout, _ := captureOutput(t, func() { runCert() }) if !strings.Contains(stdout, "Keychain Access") { t.Fatalf("stdout missing macOS trust hint: %q", stdout) } } func TestRunCert_CreatedThenExistingMessage(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) firstOut, _ := captureOutput(t, func() { runCert() }) if !strings.Contains(firstOut, "Created a new local HTTPS interception CA.") { t.Fatalf("first run should indicate created CA, got: %q", firstOut) } secondOut, _ := captureOutput(t, func() { runCert() }) if !strings.Contains(secondOut, "Using existing local HTTPS interception CA.") { t.Fatalf("second run should indicate existing CA, got: %q", secondOut) } } func captureOutput(t *testing.T, fn func()) (stdout string, stderr string) { t.Helper() origStdoutWriter := stdoutWriter origStderrWriter := stderrWriter t.Cleanup(func() { stdoutWriter = origStdoutWriter stderrWriter = origStderrWriter }) origStdout := os.Stdout origStderr := os.Stderr outR, outW, err := os.Pipe() if err != nil { t.Fatalf("stdout pipe error: %v", err) } errR, errW, err := os.Pipe() if err != nil { _ = outR.Close() _ = outW.Close() t.Fatalf("stderr pipe error: %v", err) } os.Stdout = outW os.Stderr = errW stdoutWriter = outW stderrWriter = errW outCh := make(chan string, 1) errCh := make(chan string, 1) go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, outR) outCh <- buf.String() }() go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, errR) errCh <- buf.String() }() fn() _ = outW.Close() _ = errW.Close() stdoutWriter = origStdoutWriter stderrWriter = origStderrWriter os.Stdout = origStdout os.Stderr = origStderr select { case stdout = <-outCh: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for stdout capture") } select { case stderr = <-errCh: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for stderr capture") } _ = outR.Close() _ = errR.Close() return stdout, stderr } func TestDisplayHelp_UsesInjectedStderrWriter(t *testing.T) { var buf bytes.Buffer orig := stderrWriter t.Cleanup(func() { stderrWriter = orig }) stderrWriter = &buf displayHelp() if got := buf.String(); !strings.Contains(got, "usage:") { t.Fatalf("help output missing usage, got: %q", got) } } func TestRunCert_UsesInjectedStdoutWriter(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) var buf bytes.Buffer orig := stdoutWriter t.Cleanup(func() { stdoutWriter = orig }) stdoutWriter = &buf runCert() if got := buf.String(); !strings.Contains(got, "Certificate path:") { t.Fatalf("cert output missing path line, got: %q", got) } } func installRunSeams(t *testing.T) func() { t.Helper() origStartSession := startSessionFn origRunTUI := runTUIFn return func() { startSessionFn = origStartSession runTUIFn = origRunTUI } } func installFatalSpy(t *testing.T) *bool { t.Helper() origFatal := fatalExit called := false fatalExit = func(v ...any) { called = true } t.Cleanup(func() { fatalExit = origFatal }) return &called } func TestStdioRefWrite(t *testing.T) { t.Run("writes to stdout", func(t *testing.T) { assertStdioRefWrite(t, false, "hello") }) t.Run("writes to stderr", func(t *testing.T) { assertStdioRefWrite(t, true, "boom") }) } func assertStdioRefWrite(t *testing.T, isErr bool, payload string) { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatalf("pipe error: %v", err) } defer func() { _ = r.Close() }() if isErr { orig := os.Stderr os.Stderr = w defer func() { os.Stderr = orig }() } else { orig := os.Stdout os.Stdout = w defer func() { os.Stdout = orig }() } if _, err := (stdioRef{isErr: isErr}).Write([]byte(payload)); err != nil { _ = w.Close() t.Fatalf("stdioRef write error: %v", err) } _ = w.Close() data, err := io.ReadAll(r) if err != nil { t.Fatalf("ReadAll(pipe) error: %v", err) } if got := string(data); got != payload { t.Fatalf("pipe write = %q, want %q", got, payload) } }