|
1 | 1 | package agentruntime |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "os" |
4 | 5 | "reflect" |
5 | 6 | "testing" |
6 | 7 |
|
@@ -77,6 +78,87 @@ func TestBuildExecArgs(t *testing.T) { |
77 | 78 | } |
78 | 79 | } |
79 | 80 |
|
| 81 | +// TestStreamsSupportTTY locks in the kubectl-mirroring gate: -t is requested |
| 82 | +// only when BOTH stdin and stdout are terminals. The decisive regression case |
| 83 | +// is "stdin is a terminal but stdout is a pipe" (command substitution like the |
| 84 | +// release smoke's `$(obol buy inference …)` from a tmux pane), which previously |
| 85 | +// added -t and crashed kubectl < 1.36 in its terminal-resize path. |
| 86 | +func TestStreamsSupportTTY(t *testing.T) { |
| 87 | + // A regular file is not a character device. |
| 88 | + regular, err := os.CreateTemp(t.TempDir(), "stream") |
| 89 | + if err != nil { |
| 90 | + t.Fatalf("CreateTemp: %v", err) |
| 91 | + } |
| 92 | + t.Cleanup(func() { regular.Close() }) |
| 93 | + |
| 94 | + // Pipe ends are not character devices either (they model the captured-output |
| 95 | + // case: stdout wired to a pipe under `$(...)`). |
| 96 | + pr, pw, err := os.Pipe() |
| 97 | + if err != nil { |
| 98 | + t.Fatalf("os.Pipe: %v", err) |
| 99 | + } |
| 100 | + t.Cleanup(func() { pr.Close(); pw.Close() }) |
| 101 | + |
| 102 | + // /dev/null IS a character device — a portable stand-in for a terminal here. |
| 103 | + // The "char-device stdin, pipe stdout" case is the exact regression: the old |
| 104 | + // stdin-only gate returned true (→ -t → kubectl < 1.36 panic); the two-stream |
| 105 | + // gate must return false. Guarded so the table degrades gracefully if /dev/null |
| 106 | + // is somehow unavailable. |
| 107 | + devNull, dnErr := os.Open(os.DevNull) |
| 108 | + if dnErr == nil { |
| 109 | + t.Cleanup(func() { devNull.Close() }) |
| 110 | + if !isCharDevice(devNull) { |
| 111 | + t.Fatalf("expected %s to be a character device", os.DevNull) |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + type ttyCase struct { |
| 116 | + name string |
| 117 | + in *os.File |
| 118 | + out *os.File |
| 119 | + want bool |
| 120 | + } |
| 121 | + tests := []ttyCase{ |
| 122 | + {"both pipes", pr, pw, false}, |
| 123 | + {"stdin pipe, stdout regular file", pr, regular, false}, |
| 124 | + {"stdin regular file, stdout pipe", regular, pw, false}, |
| 125 | + {"nil stdin", nil, pw, false}, |
| 126 | + {"nil stdout", pr, nil, false}, |
| 127 | + {"both nil", nil, nil, false}, |
| 128 | + } |
| 129 | + if devNull != nil { |
| 130 | + tests = append(tests, |
| 131 | + // Regression: terminal stdin + pipe stdout must NOT request a TTY. |
| 132 | + ttyCase{"char-device stdin, pipe stdout (regression)", devNull, pw, false}, |
| 133 | + // Both terminals is the only true case. |
| 134 | + ttyCase{"both char devices", devNull, devNull, true}, |
| 135 | + ) |
| 136 | + } |
| 137 | + |
| 138 | + for _, tc := range tests { |
| 139 | + t.Run(tc.name, func(t *testing.T) { |
| 140 | + if got := streamsSupportTTY(tc.in, tc.out); got != tc.want { |
| 141 | + t.Fatalf("streamsSupportTTY(%s) = %v, want %v", tc.name, got, tc.want) |
| 142 | + } |
| 143 | + }) |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// TestExecInPod_NoTTYWhenStdoutPiped is the end-to-end guarantee: when this test |
| 148 | +// process runs (under `go test`, stdout is captured, not a terminal), |
| 149 | +// shouldRequestTTY must be false so BuildExecArgs omits -t. |
| 150 | +func TestExecInPod_NoTTYWhenStdoutPiped(t *testing.T) { |
| 151 | + if shouldRequestTTY() { |
| 152 | + t.Skip("test stdout is a terminal; the piped-output gate cannot be exercised here") |
| 153 | + } |
| 154 | + args := BuildExecArgs(Hermes, DefaultInstanceID, []string{"true"}, shouldRequestTTY()) |
| 155 | + for _, a := range args { |
| 156 | + if a == "-t" { |
| 157 | + t.Fatalf("BuildExecArgs unexpectedly contains -t when stdout is not a terminal: %#v", args) |
| 158 | + } |
| 159 | + } |
| 160 | +} |
| 161 | + |
80 | 162 | func TestExecInPod_EmptyArgvRejected(t *testing.T) { |
81 | 163 | cfg := &config.Config{ConfigDir: t.TempDir(), BinDir: t.TempDir()} |
82 | 164 | err := ExecInPod(cfg, Hermes, DefaultInstanceID, nil) |
|
0 commit comments