diff --git a/README.md b/README.md index a6fcde4..eae2586 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ Sprites.close_stdin(command) Sprites.resize(command, 40, 120) ``` +#### Running after disconnect + +Commands keep running on the sprite after the client disconnects: by default +non-TTY commands are stopped 10 seconds later, while TTY commands run with no +time limit. Pass `max_run_after_disconnect` (a duration string such as `"10s"` +or `"5m"`) to `Sprites.spawn/4` or `Sprites.cmd/4` to control that timeout: + +```elixir +{:ok, command} = Sprites.spawn(sprite, "bash", [], tty: true, max_run_after_disconnect: "10s") +``` + #### Provider session identity Asynchronous callers can opt into `session_info: true` to receive diff --git a/lib/sprites.ex b/lib/sprites.ex index f6fdfc3..642f45e 100644 --- a/lib/sprites.ex +++ b/lib/sprites.ex @@ -113,6 +113,13 @@ defmodule Sprites do * `:tty` - Allocate a TTY (default: false) * `:tty_rows` - TTY rows (default: 24) * `:tty_cols` - TTY columns (default: 80) + * `:max_run_after_disconnect` - How long the command may keep running + after the client disconnects, as a Go-style duration string such as + `"10s"`, `"5m"` or `"1h30m"` (`"0"` stops it immediately; malformed + values raise `ArgumentError`). When omitted, the server default applies: + non-TTY commands (the default) are stopped 10 seconds after disconnect, + while TTY commands keep running with no time limit. Ignored when + attaching to an existing session. ## Examples @@ -143,6 +150,13 @@ defmodule Sprites do * `:tty` - Allocate a TTY (default: false) * `:tty_rows` - TTY rows (default: 24) * `:tty_cols` - TTY columns (default: 80) + * `:max_run_after_disconnect` - How long the command may keep running + after the client disconnects, as a Go-style duration string such as + `"10s"`, `"5m"` or `"1h30m"` (`"0"` stops it immediately; malformed + values raise `ArgumentError`). When omitted, the server default applies: + non-TTY commands (the default) are stopped 10 seconds after disconnect, + while TTY commands keep running with no time limit. Ignored when + attaching to an existing session. * `:session_info` - Report provider session identity to the owner as `{:session_info, %{ref: ref}, session_id}` (default: false). The ID is a string. Missing metadata yields no identity; it is never inferred from @@ -153,6 +167,9 @@ defmodule Sprites do {:ok, cmd} = Sprites.spawn(sprite, "bash", ["-i"], tty: true) + # Stop the command 10s after the client disconnects + {:ok, cmd} = Sprites.spawn(sprite, "bash", [], tty: true, max_run_after_disconnect: "10s") + receive do {:stdout, ^cmd, data} -> IO.write(data) {:exit, ^cmd, code} -> IO.puts("Done: \#{code}") diff --git a/lib/sprites/command.ex b/lib/sprites/command.ex index 836650a..ec2ed5e 100644 --- a/lib/sprites/command.ex +++ b/lib/sprites/command.ex @@ -16,6 +16,10 @@ defmodule Sprites.Command do * **Direct mode** (default) — opens a new WebSocket per command to `/exec` * **Control mode** — multiplexes over a persistent WebSocket to `/control` + + Both modes accept the same exec options (see `Sprites.spawn/4`), including + `:max_run_after_disconnect`, a duration string such as `"10s"` that limits how + long the command keeps running after the client disconnects. """ use GenServer @@ -36,9 +40,16 @@ defmodule Sprites.Command do @doc """ Starts a command asynchronously. + + Raises `ArgumentError` if `:max_run_after_disconnect` is not a duration string. """ @spec start(Sprite.t(), String.t(), [String.t()], keyword()) :: {:ok, t()} | {:error, term()} def start(sprite, command, args, opts \\ []) do + # Validate up front so bad options raise in the caller instead of crashing + # the command process (which in control mode would leak a checked-out + # connection). + _ = Sprite.max_run_after_disconnect(opts) + owner = Keyword.get(opts, :owner, self()) tty_mode = Keyword.get(opts, :tty, false) @@ -184,38 +195,47 @@ defmodule Sprites.Command do end defp build_control_args(command, args, opts, stdin) do - control_args = %{"cmd" => [command | args]} - - control_args = - case Keyword.get(opts, :dir) do - nil -> control_args - dir -> Map.put(control_args, "dir", dir) - end - - control_args = - case Keyword.get(opts, :env, []) do - [] -> - control_args + %{"cmd" => [command | args]} + |> put_control_dir(opts) + |> put_control_env(opts) + |> put_control_tty(opts) + |> put_control_max_run_after_disconnect(opts) + |> Map.put("stdin", if(stdin, do: "true", else: "false")) + end + + defp put_control_dir(control_args, opts) do + case Keyword.get(opts, :dir) do + nil -> control_args + dir -> Map.put(control_args, "dir", dir) + end + end - env_list -> - env_strs = Enum.map(env_list, fn {k, v} -> "#{k}=#{v}" end) - Map.put(control_args, "env", env_strs) - end + defp put_control_env(control_args, opts) do + case Keyword.get(opts, :env, []) do + [] -> control_args + env_list -> Map.put(control_args, "env", Enum.map(env_list, fn {k, v} -> "#{k}=#{v}" end)) + end + end - control_args = - if Keyword.get(opts, :tty, false) do - rows = Keyword.get(opts, :tty_rows, 24) - cols = Keyword.get(opts, :tty_cols, 80) + defp put_control_tty(control_args, opts) do + if Keyword.get(opts, :tty, false) do + rows = Keyword.get(opts, :tty_rows, 24) + cols = Keyword.get(opts, :tty_cols, 80) - control_args - |> Map.put("tty", "true") - |> Map.put("rows", to_string(rows)) - |> Map.put("cols", to_string(cols)) - else - control_args - end + control_args + |> Map.put("tty", "true") + |> Map.put("rows", to_string(rows)) + |> Map.put("cols", to_string(cols)) + else + control_args + end + end - Map.put(control_args, "stdin", if(stdin, do: "true", else: "false")) + defp put_control_max_run_after_disconnect(control_args, opts) do + case Sprite.max_run_after_disconnect(opts) do + nil -> control_args + duration -> Map.put(control_args, "max_run_after_disconnect", duration) + end end defp do_direct_init(sprite, command, args, opts, token, state) do diff --git a/lib/sprites/sprite.ex b/lib/sprites/sprite.ex index 8feba1a..dd4c483 100644 --- a/lib/sprites/sprite.ex +++ b/lib/sprites/sprite.ex @@ -90,9 +90,45 @@ defmodule Sprites.Sprite do client.token end + # Go `time.ParseDuration` syntax, which is what the server parses: one or + # more decimal numbers each with a unit (e.g. "10s", "1h30m", "1.5s"), or a + # bare "0". Negative durations are rejected. + @duration_regex ~r/^(?:0|(?:(?:\d+\.?\d*|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$/u + + @doc """ + Returns the validated `:max_run_after_disconnect` option, or `nil` when unset. + + The value must be a duration string in Go `time.ParseDuration` syntax, such + as `"10s"`, `"5m"`, `"1h30m"` or `"0"`. Anything else raises `ArgumentError`: + the server ignores unparseable values, which would silently disable the + timeout. Shared by direct (`/exec`) and control-mode exec. + """ + @spec max_run_after_disconnect(keyword()) :: String.t() | nil + def max_run_after_disconnect(opts) do + case Keyword.get(opts, :max_run_after_disconnect) do + nil -> + nil + + duration when is_binary(duration) -> + if Regex.match?(@duration_regex, duration) do + duration + else + raise ArgumentError, + "expected :max_run_after_disconnect to be a duration string such as " <> + "\"10s\", \"5m\" or \"1h30m\", got: #{inspect(duration)}" + end + + other -> + raise ArgumentError, + "expected :max_run_after_disconnect to be a duration string such as \"10s\", " <> + "got: #{inspect(other)}" + end + end + defp build_query_params(command, args, opts) do # When attaching to an existing session (`:session_id` set), the server - # ignores cmd/path/env/dir/detachable — they belong to the original spawn. + # ignores cmd/path/env/dir/detachable/max_run_after_disconnect — they + # belong to the original spawn. # Only stdin/tty are meaningful for the attach side. case Keyword.get(opts, :session_id) do nil -> @@ -102,6 +138,7 @@ defmodule Sprites.Sprite do |> add_env_params(opts) |> add_tty_params(opts) |> add_detachable_param(opts) + |> add_max_run_after_disconnect_param(opts) _session_id -> [] @@ -146,4 +183,11 @@ defmodule Sprites.Sprite do params end end + + defp add_max_run_after_disconnect_param(params, opts) do + case max_run_after_disconnect(opts) do + nil -> params + duration -> [{"max_run_after_disconnect", duration} | params] + end + end end diff --git a/mix.exs b/mix.exs index c91cc42..32d7434 100644 --- a/mix.exs +++ b/mix.exs @@ -10,6 +10,7 @@ defmodule Sprites.MixProject do version: @version, elixir: "~> 1.15", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), name: "Sprites", description: @@ -26,6 +27,9 @@ defmodule Sprites.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp deps do [ {:req, "~> 0.5"}, diff --git a/test/exec_options_test.exs b/test/exec_options_test.exs new file mode 100644 index 0000000..142ea7d --- /dev/null +++ b/test/exec_options_test.exs @@ -0,0 +1,180 @@ +defmodule Sprites.ExecOptionsTest do + use Sprites.Test.ExecCase, async: true + + alias Sprites.{Client, Sprite} + + describe "Sprite.exec_url/4" do + test "builds the spawn URL with command and default params" do + assert exec_url("echo", ["hi"], []) == + "wss://api.sprites.dev/v1/sprites/my-sprite/exec?stdin=false&path=echo&cmd=echo&cmd=hi" + end + + test "includes spawn options" do + query = + exec_query( + stdin: true, + dir: "/app", + env: [{"FOO", "bar"}], + tty: true, + tty_rows: 40, + tty_cols: 120, + detachable: true + ) + + assert %{ + "stdin" => "true", + "dir" => "/app", + "env" => "FOO=bar", + "tty" => "true", + "rows" => "40", + "cols" => "120", + "detachable" => "true" + } = query + end + + test "builds the attach URL with only stdin/tty params" do + url = exec_url("bash", [], session_id: "abc", dir: "/app", detachable: true, tty: true) + + assert %URI{path: "/v1/sprites/my-sprite/exec/abc", query: query} = URI.parse(url) + assert Map.keys(URI.decode_query(query)) |> Enum.sort() == ~w(cols rows stdin tty) + end + end + + describe "control-mode exec args" do + test "include cmd and stdin by default" do + assert control_op_args("echo", ["hi"], []) == %{"cmd" => ["echo", "hi"], "stdin" => "false"} + end + + test "include spawn options" do + opts = [ + stdin: true, + dir: "/app", + env: [{"FOO", "bar"}], + tty: true, + tty_rows: 40, + tty_cols: 120 + ] + + assert control_op_args("bash", [], opts) == %{ + "cmd" => ["bash"], + "dir" => "/app", + "env" => ["FOO=bar"], + "tty" => "true", + "rows" => "40", + "cols" => "120", + "stdin" => "true" + } + end + end + + describe "direct-mode exec requests" do + test "Sprites.spawn/4 connects to the spawn URL with its options" do + {path, query} = + direct_request( + &Sprites.spawn(&1, "bash", ["-l"], dir: "/app", tty: true, detachable: true) + ) + + assert path == "/v1/sprites/SPRITE/exec" + + assert %{ + "cmd" => "-l", + "path" => "bash", + "dir" => "/app", + "tty" => "true", + "detachable" => "true", + "stdin" => "false" + } = query + end + + test "Sprites.attach_session/3 connects to the attach URL without spawn options" do + {path, query} = direct_request(&Sprites.attach_session(&1, "abc", dir: "/app", tty: true)) + + assert path == "/v1/sprites/SPRITE/exec/abc" + assert Map.keys(query) |> Enum.sort() == ~w(cols rows stdin tty) + end + + test "Sprites.cmd/4 returns output and exit code from the exec stream" do + sprite = fake_sprite(frames: [<<1, "hello">>, <<3, 7>>]) + + assert Sprites.cmd(sprite, "echo", ["hello"], max_run_after_disconnect: "10s") == + {"hello", 7} + + assert_receive {:ws_request, _path, %{"max_run_after_disconnect" => "10s"}} + end + end + + describe "max_run_after_disconnect" do + test "is returned when it is a string and nil when unset" do + assert Sprite.max_run_after_disconnect(max_run_after_disconnect: "10s") == "10s" + assert Sprite.max_run_after_disconnect([]) == nil + end + + test "accepts Go duration syntax" do + for duration <- ~w(10s 5m 1h30m 1.5s .5s 500ms 0 0s) do + assert Sprite.max_run_after_disconnect(max_run_after_disconnect: duration) == duration + end + end + + test "raises ArgumentError for non-string values" do + assert_raise ArgumentError, ~r/duration string/, fn -> + Sprite.max_run_after_disconnect(max_run_after_disconnect: 10_000) + end + end + + test "raises ArgumentError for malformed duration strings" do + # The server ignores unparseable values, which would disable the timeout. + for duration <- ["10", "1d", "garbage", "", "-10s", " 10s", "10s ", "s", "1.s5"] do + assert_raise ArgumentError, ~r/duration string/, fn -> + Sprite.max_run_after_disconnect(max_run_after_disconnect: duration) + end + end + end + + test "is sent on direct-mode spawn when set" do + {_path, query} = + direct_request(&Sprites.spawn(&1, "bash", [], tty: true, max_run_after_disconnect: "10s")) + + assert query["max_run_after_disconnect"] == "10s" + end + + test "is not sent on direct-mode spawn when unset" do + {_path, query} = direct_request(&Sprites.spawn(&1, "bash", [], tty: true)) + + refute Map.has_key?(query, "max_run_after_disconnect") + end + + test "is not sent when attaching to a session" do + {_path, query} = + direct_request(&Sprites.attach_session(&1, "abc", max_run_after_disconnect: "10s")) + + refute Map.has_key?(query, "max_run_after_disconnect") + end + + test "is sent in control-mode args when set" do + args = control_op_args("bash", [], max_run_after_disconnect: "10s") + + assert args["max_run_after_disconnect"] == "10s" + end + + test "is not sent in control-mode args when unset" do + refute Map.has_key?(control_op_args("bash", [], []), "max_run_after_disconnect") + end + + test "raises in the caller for invalid values before connecting" do + direct = Sprite.new(Client.new("test-token"), "my-sprite") + + for sprite <- [direct, fake_sprite(control: true)] do + assert_raise ArgumentError, ~r/duration string/, fn -> + Sprites.spawn(sprite, "bash", [], max_run_after_disconnect: 10_000) + end + + assert_raise ArgumentError, ~r/duration string/, fn -> + Sprites.spawn(sprite, "bash", [], max_run_after_disconnect: "1d") + end + end + + # Neither transport was contacted + refute_receive {:ws_request, _, _} + end + end +end diff --git a/test/support/exec_case.ex b/test/support/exec_case.ex new file mode 100644 index 0000000..7b23c17 --- /dev/null +++ b/test/support/exec_case.ex @@ -0,0 +1,78 @@ +defmodule Sprites.Test.ExecCase do + @moduledoc """ + Test case for exercising command execution against `Sprites.Test.FakeWsServer`. + + Provides helpers to build exec URLs and to spawn commands in direct or control + mode, returning what the fake server received. + """ + + use ExUnit.CaseTemplate + + alias Sprites.{Client, Control, Sprite} + alias Sprites.Test.FakeWsServer + + using do + quote do + import Sprites.Test.ExecCase + end + end + + @doc "Builds the direct-mode exec URL for a sprite on the public API host." + @spec exec_url(String.t(), [String.t()], keyword()) :: String.t() + def exec_url(command, args, opts) do + client = Client.new("test-token", base_url: "https://api.sprites.dev") + Sprite.exec_url(Sprite.new(client, "my-sprite"), command, args, opts) + end + + @doc "Decoded query params of the exec URL for `bash` with the given options." + @spec exec_query(keyword()) :: map() + def exec_query(opts) do + exec_url("bash", [], opts) + |> URI.parse() + |> Map.fetch!(:query) + |> URI.decode_query() + end + + @doc """ + Starts a `FakeWsServer` under the test supervisor and returns a sprite + pointing at it. Options: `:control` (use control mode) and `:frames` + (binary frames the server sends once connected). + """ + @spec fake_sprite(keyword()) :: Sprite.t() + def fake_sprite(opts \\ []) do + control_mode = Keyword.get(opts, :control, false) + + server = + ExUnit.Callbacks.start_supervised!( + {FakeWsServer, [owner: self()] ++ Keyword.take(opts, [:frames])} + ) + + client = + Client.new("test-token", base_url: FakeWsServer.url(server), control_mode: control_mode) + + sprite = Sprite.new(client, "sprite-#{System.unique_integer([:positive])}") + if control_mode, do: ExUnit.Callbacks.on_exit(fn -> Control.close(sprite) end) + sprite + end + + @doc """ + Runs `fun` with a direct-mode fake sprite and returns `{path, query}` of the + WebSocket request it made, with the sprite name replaced by `SPRITE`. + """ + @spec direct_request((Sprite.t() -> {:ok, term()})) :: {String.t(), map()} + def direct_request(fun) do + sprite = fake_sprite() + {:ok, _cmd} = fun.(sprite) + assert_receive {:ws_request, path, query} + {String.replace(path, sprite.name, "SPRITE"), query} + end + + @doc "Spawns in control mode and returns the `args` of the `op.start` message." + @spec control_op_args(String.t(), [String.t()], keyword()) :: map() + def control_op_args(command, args, opts) do + {:ok, _cmd} = Sprites.spawn(fake_sprite(control: true), command, args, opts) + assert_receive {:ws_frame, :text, "control:" <> json} + assert %{"type" => "op.start", "op" => "exec", "args" => op_args} = Jason.decode!(json) + op_args + end +end diff --git a/test/support/fake_ws_server.ex b/test/support/fake_ws_server.ex new file mode 100644 index 0000000..e5ef8fe --- /dev/null +++ b/test/support/fake_ws_server.ex @@ -0,0 +1,149 @@ +defmodule Sprites.Test.FakeWsServer do + @moduledoc """ + Minimal WebSocket server standing in for the Sprites exec and control + endpoints, for use in tests. + + Accepts a single connection, completes the WebSocket upgrade, and sends the + `:owner` process (default: the caller of `start_link/1`): + + * `{:ws_request, path, query}` — the upgrade request's path and decoded query + * `{:ws_frame, :text | :binary, payload}` — each client frame, unmasked + + Options: + + * `:owner` — process to notify (pass `self()` when using `start_supervised!`) + * `:frames` — binary frames to send to the client once connected + + Use `url/1` for the server's base URL (`http://127.0.0.1:PORT`). + """ + + use GenServer + + @ws_guid "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts) do + GenServer.start_link(__MODULE__, Keyword.put_new(opts, :owner, self())) + end + + @doc "Base URL for pointing a `Sprites.Client` at this server." + @spec url(pid()) :: String.t() + def url(pid), do: "http://127.0.0.1:#{GenServer.call(pid, :port)}" + + @impl true + def init(opts) do + {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) + {:ok, port} = :inet.port(listen) + # Hand the listen socket to a helper so accept/2 doesn't block this server + server = self() + spawn_link(fn -> accept(listen, server) end) + + {:ok, + %{ + owner: Keyword.fetch!(opts, :owner), + port: port, + socket: nil, + buffer: "", + frames: Keyword.get(opts, :frames, []) + }} + end + + @impl true + def handle_call(:port, _from, state), do: {:reply, state.port, state} + + @impl true + def handle_info({:accepted, socket}, state) do + :ok = :inet.setopts(socket, active: true) + {:noreply, %{state | socket: socket}} + end + + # HTTP upgrade request (before the handshake completes) + def handle_info({:tcp, socket, data}, %{socket: socket, upgraded: _} = state) do + {:noreply, handle_frames(%{state | buffer: state.buffer <> data})} + end + + def handle_info({:tcp, socket, data}, %{socket: socket} = state) do + buffer = state.buffer <> data + + if String.contains?(buffer, "\r\n\r\n") do + [headers, rest] = String.split(buffer, "\r\n\r\n", parts: 2) + upgrade(socket, headers, state.owner) + + for frame <- state.frames, + do: :ok = :gen_tcp.send(socket, [<<0x82>>, encode_length(byte_size(frame)), frame]) + + {:noreply, handle_frames(Map.put(%{state | buffer: rest}, :upgraded, true))} + else + {:noreply, %{state | buffer: buffer}} + end + end + + def handle_info({:tcp_closed, _socket}, state), do: {:noreply, %{state | socket: nil}} + def handle_info(_message, state), do: {:noreply, state} + + defp accept(listen, server) do + {:ok, socket} = :gen_tcp.accept(listen) + :ok = :gen_tcp.controlling_process(socket, server) + send(server, {:accepted, socket}) + end + + defp upgrade(socket, headers, owner) do + [_, target] = Regex.run(~r/^GET (\S+) HTTP/, headers) + %URI{path: path, query: query} = URI.parse(target) + send(owner, {:ws_request, path, URI.decode_query(query || "")}) + + [_, key] = Regex.run(~r/sec-websocket-key:\s*(\S+)/i, headers) + accept_key = Base.encode64(:crypto.hash(:sha, key <> @ws_guid)) + + :ok = + :gen_tcp.send(socket, [ + "HTTP/1.1 101 Switching Protocols\r\n", + "upgrade: websocket\r\nconnection: Upgrade\r\n", + "sec-websocket-accept: #{accept_key}\r\n\r\n" + ]) + end + + # Decode every complete client frame in the buffer; keep any partial tail. + defp handle_frames(state) do + case decode_frame(state.buffer) do + {:ok, opcode, payload, rest} -> + send(state.owner, {:ws_frame, opcode, payload}) + handle_frames(%{state | buffer: rest}) + + :incomplete -> + state + end + end + + defp decode_frame(<<_fin::1, _rsv::3, opcode::4, 1::1, len::7, rest::binary>>) do + with {:ok, len, rest} <- payload_length(len, rest), + <> <- rest do + {:ok, opcode_name(opcode), unmask(payload, mask), rest} + else + _ -> :incomplete + end + end + + defp decode_frame(_), do: :incomplete + + defp payload_length(126, <>), do: {:ok, len, rest} + defp payload_length(127, <>), do: {:ok, len, rest} + defp payload_length(len, rest) when len < 126, do: {:ok, len, rest} + defp payload_length(_, _), do: :incomplete + + # Server frames are unmasked; lengths use the short/16-bit/64-bit forms. + defp encode_length(len) when len < 126, do: <<0::1, len::7>> + defp encode_length(len) when len < 65_536, do: <<0::1, 126::7, len::16>> + defp encode_length(len), do: <<0::1, 127::7, len::64>> + + defp opcode_name(1), do: :text + defp opcode_name(2), do: :binary + defp opcode_name(8), do: :close + defp opcode_name(_), do: :other + + defp unmask(payload, mask) do + for {byte, i} <- Enum.with_index(:binary.bin_to_list(payload)), into: <<>> do + <> + end + end +end diff --git a/test/support/fake_ws_server_test.exs b/test/support/fake_ws_server_test.exs new file mode 100644 index 0000000..9c3e5dd --- /dev/null +++ b/test/support/fake_ws_server_test.exs @@ -0,0 +1,50 @@ +defmodule Sprites.Test.FakeWsServerTest do + use ExUnit.Case, async: true + + alias Sprites.Test.FakeWsServer + + # Drive the fake server with gun, the same client the SDK uses. + defp connect(server, path) do + %URI{port: port} = URI.parse(FakeWsServer.url(server)) + {:ok, conn} = :gun.open(~c"127.0.0.1", port, %{protocols: [:http], transport: :tcp}) + {:ok, _} = :gun.await_up(conn, 1_000) + stream = :gun.ws_upgrade(conn, path, []) + assert_receive {:gun_upgrade, ^conn, ^stream, ["websocket"], _}, 1_000 + {conn, stream} + end + + test "reports the upgrade request path and query to the owner" do + server = start_supervised!({FakeWsServer, owner: self()}) + connect(server, "/v1/sprites/x/exec?cmd=ls&tty=true") + + assert_receive {:ws_request, "/v1/sprites/x/exec", %{"cmd" => "ls", "tty" => "true"}} + end + + test "reports unmasked text and binary client frames" do + server = start_supervised!({FakeWsServer, owner: self()}) + {conn, stream} = connect(server, "/") + + :gun.ws_send(conn, stream, {:text, "control:{}"}) + :gun.ws_send(conn, stream, {:binary, <<0, "stdin">>}) + # longer than 125 bytes exercises the extended length header + big = String.duplicate("x", 300) + :gun.ws_send(conn, stream, {:text, big}) + + assert_receive {:ws_frame, :text, "control:{}"} + assert_receive {:ws_frame, :binary, <<0, "stdin">>} + assert_receive {:ws_frame, :text, ^big} + end + + test "sends configured binary frames to the client" do + # 126+ and 65536+ bytes exercise the 16-bit and 64-bit length headers + medium = <<1, String.duplicate("m", 300)::binary>> + large = <<1, String.duplicate("l", 70_000)::binary>> + frames = [<<1, "out">>, medium, large, <<3, 2>>] + server = start_supervised!({FakeWsServer, owner: self(), frames: frames}) + {conn, stream} = connect(server, "/") + + for frame <- frames do + assert_receive {:gun_ws, ^conn, ^stream, {:binary, ^frame}}, 1_000 + end + end +end