Files
kandev/apps/backend/internal/gateway/websocket/terminal_messages_test.go
T
João Salavisa c534aac355 refactor(backend): split terminal_handler.go into pumps/sessions/messages (#1056)
* refactor(backend): split terminal_handler.go into pumps/sessions/messages

terminal_handler.go was 1,380 lines (1.7x the 800-line cap). Split it into
four sibling files in the same package, by responsibility:

- terminal_handler.go (525 lines): HTTP upgrade, route dispatch, top-level
  handler entries, shell-label resolution.
- terminal_pumps.go (725 lines): wsWriter, bridgeBinaryWebSockets,
  runTerminalBridge / runUserShellBridge and all input/resize/PTY-setup
  helpers. Extracts replayBufferedOutput from setupPtyAccessCommon to keep
  nestif under threshold.
- terminal_sessions.go (133 lines): execution-readiness helpers
  (ensurePassthroughExecutionReady, waitForRemoteExecutionReadyWithTimeout,
  remoteExecutionWithClientReady, shouldUseWorkspaceShell) and related
  timeout constants.
- terminal_messages.go (53 lines): protocol primitives (ResizePayload,
  isResizeCommand, resizeCommandByte, stripTerminalResponses), with a
  top-of-file doc comment describing the binary wire protocol.

Backed off the "typed TerminalMessage envelope" half of the task: the
current protocol is binary, not JSON-enveloped (terminal I/O is raw PTY
bytes; the only JSON is ResizePayload, sent as a binary frame prefixed by
0x01). Wrapping PTY bytes in a JSON envelope would require base64 and
break xterm.js AttachAddon for every client — the task brief explicitly
authorizes shipping just the file split when the format would change.

Adds terminal_messages_test.go pinning the wire format: exact JSON output
of ResizePayload, the 0x01-prefix detection, and a full resize-frame
round-trip. These fail loudly on any accidental change to the struct tags
or prefix byte.

Pure relocation otherwise: no signature changes, no behavior changes, no
frontend impact.

* refactor(backend): simplify detectInputSubmission with bytes.ContainsAny

Addresses Claude bot's PR review on #1056: replace the manual loop +
flag pattern with bytes.ContainsAny(data, "\n\r"). Behavior identical
(detect CR or LF anywhere in the input). Also drops the now-redundant
"Check for Enter key (CR or LF)" comment.

* ci: retrigger CI to re-test known-flaky session-resume spec

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-29 00:31:27 +01:00

97 lines
3.3 KiB
Go

package websocket
import (
"bytes"
"encoding/json"
"testing"
)
// TestResizePayloadWireFormat locks in the exact JSON encoding the frontend
// expects. Any change to ResizePayload field tags would break every connected
// terminal client. If this fails after a struct edit, the wire format changed
// and the frontend would need to be updated in lockstep.
func TestResizePayloadWireFormat(t *testing.T) {
tests := []struct {
name string
payload ResizePayload
want string
}{
{name: "typical 80x24", payload: ResizePayload{Cols: 80, Rows: 24}, want: `{"cols":80,"rows":24}`},
{name: "zero", payload: ResizePayload{Cols: 0, Rows: 0}, want: `{"cols":0,"rows":0}`},
{name: "max uint16", payload: ResizePayload{Cols: 65535, Rows: 65535}, want: `{"cols":65535,"rows":65535}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(got) != tt.want {
t.Fatalf("encoded = %q, want %q", got, tt.want)
}
// Round-trip: server must be able to decode what it (and the
// frontend) emit. Field names are lowercase JSON tags.
var decoded ResizePayload
if err := json.Unmarshal(got, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded != tt.payload {
t.Fatalf("round-trip = %+v, want %+v", decoded, tt.payload)
}
})
}
}
// TestIsResizeCommandWireFormat documents the binary protocol prefix used to
// distinguish resize commands from raw PTY input. Changing either resizeCommandByte
// or the leading-'{' heuristic would silently route resize frames to the PTY (and
// vice versa) for any connected client.
func TestIsResizeCommandWireFormat(t *testing.T) {
tests := []struct {
name string
data []byte
want bool
}{
{name: "resize prefix + json", data: []byte{0x01, '{', '}'}, want: true},
{name: "resize prefix with realistic payload", data: append([]byte{0x01}, []byte(`{"cols":80,"rows":24}`)...), want: true},
{name: "bare 0x01 (Ctrl+A)", data: []byte{0x01}, want: false},
{name: "0x01 followed by non-brace", data: []byte{0x01, 'X'}, want: false},
{name: "regular input", data: []byte("ls\n"), want: false},
{name: "empty", data: []byte{}, want: false},
{name: "json without prefix", data: []byte(`{"cols":80,"rows":24}`), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isResizeCommand(tt.data); got != tt.want {
t.Fatalf("isResizeCommand(%q) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
// TestResizeFramePayloadParseable verifies the full wire frame (prefix byte +
// JSON body) round-trips through the handler's parsing path: strip the prefix,
// json.Unmarshal the remainder into ResizePayload.
func TestResizeFramePayloadParseable(t *testing.T) {
frame := []byte{0x01}
frame = append(frame, []byte(`{"cols":120,"rows":40}`)...)
if !isResizeCommand(frame) {
t.Fatalf("frame not detected as resize: %q", frame)
}
if !bytes.HasPrefix(frame, []byte{resizeCommandByte}) {
t.Fatalf("frame missing resize prefix byte")
}
var resize ResizePayload
if err := json.Unmarshal(frame[1:], &resize); err != nil {
t.Fatalf("unmarshal frame payload: %v", err)
}
if resize.Cols != 120 || resize.Rows != 40 {
t.Fatalf("parsed = %+v, want cols=120 rows=40", resize)
}
}