From 6734bc3ac61ab977b7f61ebdc6f19798812ca13f Mon Sep 17 00:00:00 2001 From: Brad Gessler Date: Mon, 22 Jun 2026 00:21:28 -0700 Subject: [PATCH] test: cover the pure protocol units (Window, Codec, Protocol, ErrorCode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone suite left the pure, sans-IO protocol modules thin — Window (the flow-control ledger) and Protocol.ErrorCode were at 0%, Codec 66%, Protocol 57%. These are the cross-language contract; they deserve dedicated unit tests, not just incidental coverage from the corpus run (which is excluded standalone). - window_test: new/take/grant — credit math, the never-negative take, draining, and the MAX_WINDOW ceiling clamp on both new/1 and grant/2. - protocol_test: pins the wire constants (version, control sid, default/max window, capabilities) and every frame type / signal / error-code token. - codec_test: encode/decode round-trips (incl. control sid 0 and the max signed-64 sid) and every decode rejection (non-map, corrupt msgpack, missing/empty/non-string 't', missing/non-integer/negative/out-of-range 'sid'). Also documents that bin payloads decode as Msgpax.Bin (binary: true) so a re-encode keeps the bin type. Also fixes a misleading codec doc comment (it claimed bin decodes to a plain binary; it's a Msgpax.Bin the Session unwraps). Total coverage 87% -> 92%; Window/Codec/Protocol/ErrorCode now 100%. --- lib/terminalwire/codec.ex | 4 +- test/codec_test.exs | 96 ++++++++++++++++++++++++++++++++++++ test/protocol_test.exs | 73 +++++++++++++++++++++++++++ test/window_test.exs | 101 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 test/codec_test.exs create mode 100644 test/protocol_test.exs create mode 100644 test/window_test.exs diff --git a/lib/terminalwire/codec.ex b/lib/terminalwire/codec.ex index 9221931..c7d88c3 100644 --- a/lib/terminalwire/codec.ex +++ b/lib/terminalwire/codec.ex @@ -32,7 +32,9 @@ defmodule Terminalwire.Codec do @doc """ Decode MessagePack bytes for exactly one frame. Returns the frame map (string keys). Raises `ProtocolError` for anything that isn't a well-formed frame. - `bin` values come back as plain binaries (unpack with binary: true). + `bin` values come back wrapped in `Msgpax.Bin` (binary: true) so a decode then + re-encode preserves the MessagePack `bin` type instead of degrading to `str`; + consumers unwrap them before use (see `Terminalwire.Server.Session`). """ def decode(bytes) when is_binary(bytes) do frame = diff --git a/test/codec_test.exs b/test/codec_test.exs new file mode 100644 index 0000000..97f0545 --- /dev/null +++ b/test/codec_test.exs @@ -0,0 +1,96 @@ +defmodule Terminalwire.CodecTest do + use ExUnit.Case, async: true + + alias Terminalwire.{Codec, Frames, ProtocolError} + + describe "encode/1" do + test "encodes a frame map to a MessagePack binary" do + bytes = Codec.encode(%{"t" => "hello", "sid" => 0}) + assert is_binary(bytes) + end + + test "raises for a non-map frame" do + assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.encode([1, 2, 3]) end + assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.encode("nope") end + end + end + + describe "round-trip" do + test "encode then decode returns an equivalent frame (string keys)" do + frame = %{"t" => "request", "sid" => 7, "resource" => "file", "method" => "read"} + assert frame |> Codec.encode() |> Codec.decode() == frame + end + + test "binary payloads decode as Msgpax.Bin so a re-encode keeps them MessagePack bin" do + raw = <<0, 255, 1, 2, 254>> + decoded = Frames.data(3, raw) |> Codec.encode() |> Codec.decode() + # Decoding keeps `bin` wrapped in Msgpax.Bin (binary: true) so re-encoding a + # decoded frame preserves the bin type instead of degrading to str. Consumers + # (the Session) unwrap it before handing bytes to the CLI. The bytes are intact. + assert %Msgpax.Bin{data: ^raw} = decoded["bytes"] + end + + test "the control stream id (0) round-trips" do + assert %{"t" => "signal", "sid" => 0} |> Codec.encode() |> Codec.decode() == %{ + "t" => "signal", + "sid" => 0 + } + end + + test "the maximum signed-64-bit sid round-trips" do + max = 0x7FFFFFFFFFFFFFFF + + assert %{"t" => "data", "sid" => max} |> Codec.encode() |> Codec.decode() == %{ + "t" => "data", + "sid" => max + } + end + end + + describe "decode/1 rejects malformed input" do + test "non-map MessagePack" do + bytes = Msgpax.pack!(123, iodata: false) + assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.decode(bytes) end + end + + test "corrupt MessagePack bytes" do + # 0xC1 is the one byte MessagePack never assigns — a clean 'malformed' probe. + assert_raise ProtocolError, ~r/malformed msgpack/, fn -> Codec.decode(<<0xC1>>) end + end + + test "missing 't'" do + bytes = Codec.encode(%{"sid" => 1}) + assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end + end + + test "empty 't'" do + bytes = Codec.encode(%{"t" => "", "sid" => 1}) + assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end + end + + test "non-string 't'" do + bytes = Codec.encode(%{"t" => 5, "sid" => 1}) + assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end + end + + test "missing 'sid'" do + bytes = Codec.encode(%{"t" => "data"}) + assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end + end + + test "non-integer 'sid'" do + bytes = Codec.encode(%{"t" => "data", "sid" => "1"}) + assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end + end + + test "negative 'sid'" do + bytes = Codec.encode(%{"t" => "data", "sid" => -1}) + assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end + end + + test "'sid' beyond the signed 64-bit range (would wrap negative in Go)" do + bytes = Codec.encode(%{"t" => "data", "sid" => 0x8000000000000000}) + assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end + end + end +end diff --git a/test/protocol_test.exs b/test/protocol_test.exs new file mode 100644 index 0000000..d3e3d2d --- /dev/null +++ b/test/protocol_test.exs @@ -0,0 +1,73 @@ +defmodule Terminalwire.ProtocolTest do + use ExUnit.Case, async: true + + alias Terminalwire.Protocol + + # These values are part of the cross-language wire contract (Ruby, Go, Elixir all + # agree). Pinning them here guards against an accidental change that would silently + # break interop; a deliberate change must move in lockstep with the corpus. + describe "version constants" do + test "speaks protocol version 2, with min == max == 2" do + assert Protocol.version() == 2 + assert Protocol.min_version() == 2 + assert Protocol.max_version() == 2 + end + + test "the control stream id is 0" do + assert Protocol.control_sid() == 0 + end + end + + describe "flow-control windows" do + test "default per-stream window is 256 KiB" do + assert Protocol.default_window() == 256 * 1024 + end + + test "max window ceiling is 16 MiB and is 64x the default offer" do + assert Protocol.max_window() == 16 * 1024 * 1024 + assert Protocol.max_window() == Protocol.default_window() * 64 + end + end + + describe "capabilities" do + test "advertises the full v2 capability set" do + assert Protocol.capabilities() == ~w( + stdio file directory browser env signal flow raw-input terminal-query + ) + end + end + + describe "frame type tokens" do + test "covers all eleven frame types with their wire spelling" do + alias Protocol.Type + + assert {Type.hello(), Type.welcome(), Type.incompatible()} == + {"hello", "welcome", "incompatible"} + + assert {Type.exit(), Type.open(), Type.data(), Type.close()} == + {"exit", "open", "data", "close"} + + assert {Type.request(), Type.response(), Type.signal(), Type.window_adjust()} == + {"request", "response", "signal", "window_adjust"} + end + end + + describe "signal names" do + test "carries resize and interrupt" do + assert Protocol.Signal.resize() == "resize" + assert Protocol.Signal.interrupt() == "interrupt" + end + end + + describe "error codes" do + test "covers every error code carried on a failed response" do + alias Protocol.ErrorCode + + assert ErrorCode.denied() == "denied" + assert ErrorCode.not_found() == "not_found" + assert ErrorCode.io() == "io" + assert ErrorCode.protocol() == "protocol" + assert ErrorCode.internal() == "internal" + end + end +end diff --git a/test/window_test.exs b/test/window_test.exs new file mode 100644 index 0000000..d3a781f --- /dev/null +++ b/test/window_test.exs @@ -0,0 +1,101 @@ +defmodule Terminalwire.WindowTest do + use ExUnit.Case, async: true + + alias Terminalwire.{Protocol, Window} + + @max Protocol.max_window() + + describe "new/1" do + test "stores the offered size as available credit" do + assert Window.available(Window.new(1024)) == 1024 + end + + test "a zero window starts with no credit" do + assert Window.available(Window.new(0)) == 0 + end + + test "clamps an offer above the protocol ceiling to max_window" do + assert Window.available(Window.new(@max + 1)) == @max + assert Window.available(Window.new(@max * 10)) == @max + end + + test "an offer at exactly the ceiling is kept" do + assert Window.available(Window.new(@max)) == @max + end + end + + describe "take/2" do + test "takes the full amount when credit covers it and debits the window" do + {taken, w} = Window.take(Window.new(1000), 400) + assert taken == 400 + assert Window.available(w) == 600 + end + + test "takes only what's available when the request exceeds credit" do + {taken, w} = Window.take(Window.new(300), 1000) + assert taken == 300 + assert Window.available(w) == 0 + end + + test "takes nothing from an empty window" do + {taken, w} = Window.take(Window.new(0), 500) + assert taken == 0 + assert Window.available(w) == 0 + end + + test "taking zero is a no-op" do + {taken, w} = Window.take(Window.new(500), 0) + assert taken == 0 + assert Window.available(w) == 500 + end + + test "a negative request never returns negative or grows the window" do + {taken, w} = Window.take(Window.new(500), -100) + assert taken == 0 + assert Window.available(w) == 500 + end + + test "successive takes drain the window exactly" do + w = Window.new(100) + {a, w} = Window.take(w, 60) + {b, w} = Window.take(w, 60) + {c, w} = Window.take(w, 60) + assert {a, b, c} == {60, 40, 0} + assert Window.available(w) == 0 + end + end + + describe "grant/2" do + test "extends available credit when a window_adjust arrives" do + w = Window.new(100) |> Window.grant(250) + assert Window.available(w) == 350 + end + + test "clamps the total to the protocol ceiling so a peer can't grow it unbounded" do + w = Window.new(@max) |> Window.grant(1) + assert Window.available(w) == @max + end + + test "a single oversized grant is clamped to the ceiling" do + w = Window.new(0) |> Window.grant(@max * 5) + assert Window.available(w) == @max + end + + test "granting zero leaves the window unchanged" do + assert Window.available(Window.grant(Window.new(100), 0)) == 100 + end + end + + describe "take/grant interplay" do + test "a drained window refills on grant and can be taken from again" do + w = Window.new(100) + {100, w} = Window.take(w, 100) + assert Window.available(w) == 0 + + w = Window.grant(w, 50) + {taken, w} = Window.take(w, 80) + assert taken == 50 + assert Window.available(w) == 0 + end + end +end