| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/control" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/i18n" |
| 16 | ) |
| 17 | |
| 18 | func TestTUIDiagnosticsKeepProcessAndPluginLogsOffTerminal(t *testing.T) { |
| 19 | var terminal bytes.Buffer |
| 20 | beforeTest := slog.Default() |
| 21 | terminalLogger := slog.New(slog.NewTextHandler(&terminal, nil)) |
| 22 | slog.SetDefault(terminalLogger) |
| 23 | t.Cleanup(func() { slog.SetDefault(beforeTest) }) |
| 24 | |
| 25 | d := startTUIDiagnostics(t.TempDir()) |
| 26 | t.Cleanup(d.Close) |
| 27 | slog.Warn("controller: snapshot conflict", "path", "private-session.jsonl") |
| 28 | fmt.Fprintln(d.Writer(), "plugin diagnostic") |
| 29 | if got := terminal.String(); got != "" { |
| 30 | t.Fatalf("terminal received diagnostics while TUI owned it: %q", got) |
| 31 | } |
| 32 | |
| 33 | logPath := d.path |
| 34 | d.Close() |
| 35 | data, err := os.ReadFile(logPath) |
| 36 | if err != nil { |
| 37 | t.Fatalf("read TUI diagnostic log: %v", err) |
| 38 | } |
| 39 | got := string(data) |
| 40 | for _, want := range []string{"controller: snapshot conflict", "private-session.jsonl", "plugin diagnostic"} { |
| 41 | if !strings.Contains(got, want) { |
| 42 | t.Fatalf("diagnostic log = %q, want %q", got, want) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | slog.Warn("after TUI") |
| 47 | if got := terminal.String(); !strings.Contains(got, "after TUI") { |
| 48 | t.Fatalf("previous logger was not restored after TUI close: %q", got) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestTUIDiagnosticsFallBackToDiscardWithoutLeakingToTerminal(t *testing.T) { |
| 53 | var terminal bytes.Buffer |
| 54 | beforeTest := slog.Default() |
| 55 | terminalLogger := slog.New(slog.NewTextHandler(&terminal, nil)) |
| 56 | slog.SetDefault(terminalLogger) |
| 57 | t.Cleanup(func() { slog.SetDefault(beforeTest) }) |
| 58 | |
| 59 | blockedHome := filepath.Join(t.TempDir(), "not-a-directory") |
| 60 | if err := os.WriteFile(blockedHome, []byte("file"), 0o600); err != nil { |
| 61 | t.Fatalf("seed blocked home: %v", err) |
| 62 | } |
| 63 | d := startTUIDiagnostics(blockedHome) |
| 64 | defer d.Close() |
| 65 | |
| 66 | slog.Warn("must stay off terminal") |
| 67 | fmt.Fprintln(d.Writer(), "plugin must stay off terminal") |
| 68 | if got := terminal.String(); got != "" { |
| 69 | t.Fatalf("fallback leaked diagnostics to terminal: %q", got) |
| 70 | } |
| 71 | if d.path != "" { |
| 72 | t.Fatalf("fallback diagnostic path = %q, want empty", d.path) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestBoundedDiagnosticWriterStopsAtLimit(t *testing.T) { |
| 77 | var dst bytes.Buffer |
| 78 | w := &boundedDiagnosticWriter{dst: &dst, remaining: 8} |
| 79 | payload := strings.Repeat("x", 32) |
| 80 | n, err := io.WriteString(w, payload) |
| 81 | if err != nil || n != len(payload) { |
| 82 | t.Fatalf("Write = (%d, %v), want (%d, nil)", n, err, len(payload)) |
| 83 | } |
| 84 | if !strings.HasPrefix(dst.String(), strings.Repeat("x", 8)) { |
| 85 | t.Fatalf("bounded output = %q, want eight payload bytes first", dst.String()) |
| 86 | } |
| 87 | if !strings.Contains(dst.String(), "diagnostic log limit reached") { |
| 88 | t.Fatalf("bounded output = %q, want truncation marker", dst.String()) |
| 89 | } |
| 90 | before := dst.Len() |
| 91 | if _, err := io.WriteString(w, "more"); err != nil { |
| 92 | t.Fatalf("discard after cap: %v", err) |
| 93 | } |
| 94 | if dst.Len() != before { |
| 95 | t.Fatalf("writer grew after cap: before=%d after=%d", before, dst.Len()) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestCLIProfileBuildOptionsPropagateInteractiveOwners(t *testing.T) { |
| 100 | var diagnostic bytes.Buffer |
| 101 | recovered := false |
| 102 | onRecovered := func(control.SessionRecoveryInfo) error { |
| 103 | recovered = true |
| 104 | return nil |
| 105 | } |
| 106 | opts := cliProfileBuildOptions("provider/model", 9, false, event.Discard, "delivery", cliBuildOverrides{ |
| 107 | WorkspaceRoot: "/workspace", |
| 108 | HeadlessApprovalMode: control.ToolApprovalAuto, |
| 109 | Stderr: &diagnostic, |
| 110 | OnSessionRecovered: onRecovered, |
| 111 | }) |
| 112 | |
| 113 | if opts.Stderr != &diagnostic { |
| 114 | t.Fatalf("Stderr = %T, want caller-owned diagnostic writer", opts.Stderr) |
| 115 | } |
| 116 | if opts.OnSessionRecovered == nil { |
| 117 | t.Fatal("OnSessionRecovered was dropped from CLI build options") |
| 118 | } |
| 119 | if err := opts.OnSessionRecovered(control.SessionRecoveryInfo{RecoveryPath: "recovery.jsonl"}); err != nil { |
| 120 | t.Fatalf("OnSessionRecovered: %v", err) |
| 121 | } |
| 122 | if !recovered { |
| 123 | t.Fatal("propagated recovery callback was not invoked") |
| 124 | } |
| 125 | if opts.HeadlessApprovalMode != control.ToolApprovalAuto { |
| 126 | t.Fatalf("HeadlessApprovalMode = %q, want %q", opts.HeadlessApprovalMode, control.ToolApprovalAuto) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestCLIProfileBuildOptionsUseResolvedLocaleForAutoPricing(t *testing.T) { |
| 131 | defer i18n.DetectLanguage("en") |
| 132 | for _, tt := range []struct { |
| 133 | language string |
| 134 | want string |
| 135 | }{ |
| 136 | {language: "en", want: "USD"}, |
| 137 | {language: "zh", want: "CNY"}, |
| 138 | {language: "zh-TW", want: "CNY"}, |
| 139 | } { |
| 140 | i18n.DetectLanguage(tt.language) |
| 141 | opts := cliProfileBuildOptions("provider/model", 0, false, event.Discard, "balanced", cliBuildOverrides{}) |
| 142 | if opts.AutoPricingCurrency != tt.want { |
| 143 | t.Errorf("language %q auto pricing currency = %q, want %q", tt.language, opts.AutoPricingCurrency, tt.want) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestTUIDiagnosticsMilestoneFlushesNonEmptyLog(t *testing.T) { |
| 149 | home := t.TempDir() |
| 150 | d := startTUIDiagnostics(home) |
| 151 | t.Cleanup(d.Close) |
| 152 | d.Milestone("config_load_begin") |
| 153 | d.Milestone("controller_build_done") |
| 154 | if d.Path() == "" { |
| 155 | t.Fatal("expected diagnostic log path") |
| 156 | } |
| 157 | body, err := os.ReadFile(d.Path()) |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | if len(body) == 0 { |
| 162 | t.Fatal("diagnostic log must not be empty after milestones") |
| 163 | } |
| 164 | for _, want := range []string{"diagnostics_started", "config_load_begin", "controller_build_done"} { |
| 165 | if !strings.Contains(string(body), want) { |
| 166 | t.Fatalf("log missing %q:\n%s", want, body) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 |