package mcp // Streaming tests for sshExecStream against a real SSH endpoint. Guarded by // OIKOS_SSH_TEST_HOST — skipped when unset. Run with: // // OIKOS_SSH_TEST_HOST=localhost OIKOS_SSH_USER=$USER \ // OIKOS_SSH_KEY_PATH=~/.ssh/id_ed25519 go test ./internal/mcp/ -run TestSSHExecStream // // These matter because the whole point of the change is behaviour that only // appears over time: that output arrives *before* the command exits, and that // a command killed mid-flight still leaves what it printed. import ( "context" "os" "strings" "sync" "testing" "time" ) func sshTestHost(t *testing.T) string { t.Helper() host := os.Getenv("OIKOS_SSH_TEST_HOST") if host == "" { t.Skip("OIKOS_SSH_TEST_HOST not set — skipping live SSH test") } return host } // The core claim: chunks reach the sink while the command is still running, // not in one lump at the end. A command that prints, sleeps, then prints must // deliver its first chunk well before it exits. func TestSSHExecStreamDeliversOutputBeforeExit(t *testing.T) { host := sshTestHost(t) var ( mu sync.Mutex chunks []string firstA time.Time ) sink := func(stream string, chunk []byte) { mu.Lock() defer mu.Unlock() if firstA.IsZero() { firstA = time.Now() } chunks = append(chunks, string(chunk)) } start := time.Now() out, err := sshExecStream(context.Background(), host, os.Getenv("OIKOS_SSH_USER"), "echo FIRST; sleep 2; echo SECOND", sink) elapsed := time.Since(start) if err != nil { t.Fatalf("sshExecStream: %v (out=%q)", err, out) } mu.Lock() joined := strings.Join(chunks, "") firstAt := firstA.Sub(start) mu.Unlock() if !strings.Contains(out, "FIRST") || !strings.Contains(out, "SECOND") { t.Errorf("combined output lost content: %q", out) } if !strings.Contains(joined, "FIRST") || !strings.Contains(joined, "SECOND") { t.Errorf("sink did not receive the full output: %q", joined) } if elapsed < 2*time.Second { t.Fatalf("command returned in %v — the sleep did not run, test is not measuring what it claims", elapsed) } // The first chunk must land near the start, not at the end. if firstAt > elapsed/2 { t.Errorf("first chunk arrived after %v of a %v command — output is still being buffered to the end", firstAt, elapsed) } } // stderr must reach the sink too, and land in the combined output, matching // what CombinedOutput used to return. func TestSSHExecStreamCapturesBothStreams(t *testing.T) { host := sshTestHost(t) var mu sync.Mutex seen := map[string]bool{} sink := func(stream string, chunk []byte) { mu.Lock() defer mu.Unlock() seen[stream] = true } out, err := sshExecStream(context.Background(), host, os.Getenv("OIKOS_SSH_USER"), "echo TO_STDOUT; echo TO_STDERR 1>&2", sink) if err != nil { t.Fatalf("sshExecStream: %v (out=%q)", err, out) } if !strings.Contains(out, "TO_STDOUT") || !strings.Contains(out, "TO_STDERR") { t.Errorf("combined output missing a stream: %q", out) } mu.Lock() defer mu.Unlock() if !seen["stdout"] { t.Error("sink never saw a stdout chunk") } if !seen["stderr"] { t.Error("sink never saw a stderr chunk") } } // A cancelled command used to return "" — everything it had printed was // thrown away. The hung case is exactly when that output is worth having. func TestSSHExecStreamKeepsPartialOutputOnCancel(t *testing.T) { host := sshTestHost(t) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := sshExecStream(ctx, host, os.Getenv("OIKOS_SSH_USER"), "echo BEFORE_HANG; sleep 30; echo NEVER", nil) if err == nil { t.Fatal("expected a context error for a command that outlives the deadline") } if !strings.Contains(out, "BEFORE_HANG") { t.Errorf("partial output was discarded on cancel: %q", out) } if strings.Contains(out, "NEVER") { t.Errorf("command should not have completed: %q", out) } } // A nil sink must behave exactly as the old CombinedOutput path did. func TestSSHExecNilSinkStillReturnsOutput(t *testing.T) { host := sshTestHost(t) out, err := sshExec(context.Background(), host, os.Getenv("OIKOS_SSH_USER"), "echo PLAIN") if err != nil { t.Fatalf("sshExec: %v", err) } if out != "PLAIN" { t.Errorf("out = %q, want %q (output is trimmed)", out, "PLAIN") } } // A non-zero exit must surface as an error while still returning the output. func TestSSHExecStreamNonZeroExitIsAnError(t *testing.T) { host := sshTestHost(t) out, err := sshExecStream(context.Background(), host, os.Getenv("OIKOS_SSH_USER"), "echo PRINTED_THEN_FAILED; exit 3", nil) if err == nil { t.Fatal("a non-zero exit that printed output must still be an error") } if !strings.Contains(out, "PRINTED_THEN_FAILED") { t.Errorf("output lost on failure: %q", out) } }