diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d18a0..96d4692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,4 +161,4 @@ jobs: elixir_versions: '["1.18"]' test_command: >- mix coveralls.json && - MIX_ENV=test mix run -e 'minimum = 73.0; data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}% (minimum #{minimum}%)"); if percent < minimum, do: System.halt(1)' + MIX_ENV=test mix run -e 'minimum = 83.5; data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}% (minimum #{minimum}%)"); if percent < minimum, do: System.halt(1)' diff --git a/test/jido/shell/exec_test.exs b/test/jido/shell/exec_test.exs new file mode 100644 index 0000000..27d09f6 --- /dev/null +++ b/test/jido/shell/exec_test.exs @@ -0,0 +1,54 @@ +defmodule Jido.Shell.ExecTest do + use ExUnit.Case, async: true + + alias Jido.Shell.Exec + + defmodule FakeShellAgent do + def run(session_id, command, opts) do + send(self(), {:shell_run, session_id, command, opts}) + Process.get(:exec_fake_result, {:ok, ""}) + end + end + + test "run/4 trims output and uses default timeout" do + Process.put(:exec_fake_result, {:ok, " hello world\n"}) + + assert {:ok, "hello world"} = Exec.run(FakeShellAgent, "sess-1", "echo hello") + + assert_receive {:shell_run, "sess-1", "echo hello", [timeout: 60_000]} + end + + test "run/4 passes explicit timeout" do + Process.put(:exec_fake_result, {:ok, "done\n"}) + + assert {:ok, "done"} = Exec.run(FakeShellAgent, "sess-1", "echo done", timeout: 1_500) + + assert_receive {:shell_run, "sess-1", "echo done", [timeout: 1_500]} + end + + test "run/4 passes through errors" do + Process.put(:exec_fake_result, {:error, :boom}) + + assert {:error, :boom} = Exec.run(FakeShellAgent, "sess-1", "bad command") + end + + test "run_in_dir/5 wraps command with escaped cwd" do + Process.put(:exec_fake_result, {:ok, "ok\n"}) + + assert {:ok, "ok"} = + Exec.run_in_dir( + FakeShellAgent, + "sess-1", + "/tmp/it's/quoted", + "echo ok", + timeout: 50 + ) + + assert_receive {:shell_run, "sess-1", command, [timeout: 50]} + assert command == "cd '/tmp/it'\\''s/quoted' && echo ok" + end + + test "escape_path/1 escapes single quotes" do + assert Exec.escape_path("/a'b/c") == "'/a'\\''b/c'" + end +end diff --git a/test/jido/shell/sprite_lifecycle_test.exs b/test/jido/shell/sprite_lifecycle_test.exs new file mode 100644 index 0000000..5f8a28c --- /dev/null +++ b/test/jido/shell/sprite_lifecycle_test.exs @@ -0,0 +1,314 @@ +defmodule Jido.Shell.SpriteLifecycleTest do + use ExUnit.Case, async: true + + alias Jido.Shell.SpriteLifecycle + + defmodule FakeSessionMod do + def start_with_vfs(workspace_id, opts) do + send(self(), {:start_with_vfs, workspace_id, opts}) + Process.get(:sprite_start_result, {:ok, "sess-default"}) + end + end + + defmodule FakeAgentMod do + def run(session_id, command, opts) do + send(self(), {:agent_run, session_id, command, opts}) + Process.get(:sprite_agent_run_result, {:ok, "ok\n"}) + end + + def stop(session_id) do + send(self(), {:agent_stop, session_id}) + Process.get(:sprite_agent_stop_result, :ok) + end + end + + defmodule FakeSpritesMod do + def new(token, opts) do + send(self(), {:sprites_new, token, opts}) + {:client, token, opts} + end + + def get_sprite(client, sprite_name) do + send(self(), {:get_sprite_called, client, sprite_name}) + shift({:sprites_get_script, __MODULE__}, {:error, :not_found}) + end + + def sprite(client, sprite_name) do + send(self(), {:sprite_called, client, sprite_name}) + {:sprite, client, sprite_name} + end + + def destroy(sprite) do + send(self(), {:destroy_called, sprite}) + shift({:sprites_destroy_script, __MODULE__}, :ok) + end + + defp shift(key, default) do + case Process.get(key, default) do + [head | tail] -> + Process.put(key, tail) + head + + value -> + value + end + end + end + + defmodule FakeSpritesNoGet do + def new(token, opts), do: {:client, token, opts} + end + + defmodule FakeSpritesNoDestroy do + def new(token, opts), do: {:client, token, opts} + + def get_sprite(_client, _sprite_name) do + shift({:sprites_get_script, __MODULE__}, {:ok, %{id: "sprite"}}) + end + + def sprite(client, sprite_name), do: {:sprite, client, sprite_name} + + defp shift(key, default) do + case Process.get(key, default) do + [head | tail] -> + Process.put(key, tail) + head + + value -> + value + end + end + end + + test "provision/3 starts sprite session and prepares workspace directory" do + Process.put(:sprite_start_result, {:ok, "sess-123"}) + Process.put(:sprite_agent_run_result, {:ok, "created\n"}) + + sprite_config = %{ + "token" => "token-123", + "create" => false, + "base_url" => "https://sprites.example", + "env" => %{"MODE" => "test"} + } + + assert {:ok, result} = + SpriteLifecycle.provision( + "workspace-1", + sprite_config, + session_mod: FakeSessionMod, + agent_mod: FakeAgentMod + ) + + assert result == %{ + session_id: "sess-123", + sprite_name: "workspace-1", + workspace_dir: "/work/workspace-1", + workspace_id: "workspace-1" + } + + assert_receive {:start_with_vfs, "workspace-1", session_opts} + + assert {Jido.Shell.Backend.Sprite, + %{sprite_name: "workspace-1", token: "token-123", create: false, base_url: "https://sprites.example"}} = + Keyword.fetch!(session_opts, :backend) + + assert Keyword.fetch!(session_opts, :env) == %{"MODE" => "test"} + + assert_receive {:agent_run, "sess-123", "mkdir -p /work/workspace-1", [timeout: 30_000]} + end + + test "provision/3 honors overrides and returns mkdir errors" do + Process.put(:sprite_start_result, {:ok, "sess-override"}) + Process.put(:sprite_agent_run_result, {:error, :mkdir_failed}) + + assert {:error, :mkdir_failed} = + SpriteLifecycle.provision( + "workspace-2", + %{token: "token-override", base_url: " "}, + session_mod: FakeSessionMod, + agent_mod: FakeAgentMod, + workspace_base: "/tmp/work", + workspace_dir: "/custom/ws", + sprite_name: "sprite-override", + timeout: 99 + ) + + assert_receive {:start_with_vfs, "workspace-2", session_opts} + + assert {Jido.Shell.Backend.Sprite, %{sprite_name: "sprite-override", token: "token-override", create: true}} = + Keyword.fetch!(session_opts, :backend) + + assert_receive {:agent_run, "sess-override", "mkdir -p /custom/ws", [timeout: 99]} + end + + test "provision/3 propagates session startup errors" do + Process.put(:sprite_start_result, {:error, :session_failed}) + + assert {:error, :session_failed} = + SpriteLifecycle.provision( + "workspace-3", + %{token: "token"}, + session_mod: FakeSessionMod, + agent_mod: FakeAgentMod + ) + + refute_receive {:agent_run, _, _, _}, 20 + end + + test "teardown/2 verifies absent sprites immediately" do + Process.put(:sprite_agent_stop_result, :ok) + Process.put({:sprites_get_script, FakeSpritesMod}, [{:error, :not_found}]) + + assert %{teardown_verified: true, teardown_attempts: 1, warnings: nil} = + SpriteLifecycle.teardown( + "sess-t1", + sprite_name: "sprite-1", + stop_mod: FakeAgentMod, + sprite_config: %{token: "token"}, + sprites_mod: FakeSpritesMod, + retry_backoffs_ms: [0, 0] + ) + + assert_receive {:agent_stop, "sess-t1"} + assert_receive {:sprites_new, "token", []} + assert_receive {:get_sprite_called, {:client, "token", []}, "sprite-1"} + refute_receive {:destroy_called, _}, 20 + end + + test "teardown/2 destroys present sprites and verifies removal" do + Process.put(:sprite_agent_stop_result, :ok) + + Process.put({:sprites_get_script, FakeSpritesMod}, [ + {:ok, %{id: "sprite-2"}}, + {:error, %{status: 404}} + ]) + + Process.put({:sprites_destroy_script, FakeSpritesMod}, [:ok]) + + assert %{teardown_verified: true, teardown_attempts: 1, warnings: nil} = + SpriteLifecycle.teardown( + "sess-t2", + sprite_name: "sprite-2", + stop_mod: FakeAgentMod, + sprite_config: %{token: "token"}, + sprites_mod: FakeSpritesMod, + retry_backoffs_ms: [0] + ) + + assert_receive {:destroy_called, {:sprite, {:client, "token", []}, "sprite-2"}} + end + + test "teardown/2 retries and reports warnings when verification never succeeds" do + Process.put(:sprite_agent_stop_result, {:error, :already_stopped}) + + Process.put({:sprites_get_script, FakeSpritesMod}, [ + {:ok, %{id: "sprite-3"}}, + {:ok, %{id: "sprite-3"}}, + {:ok, %{id: "sprite-3"}}, + {:ok, %{id: "sprite-3"}} + ]) + + Process.put({:sprites_destroy_script, FakeSpritesMod}, [ + {:error, :denied}, + {:error, :still_denied} + ]) + + result = + SpriteLifecycle.teardown( + "sess-t3", + sprite_name: "sprite-3", + stop_mod: FakeAgentMod, + sprite_config: %{token: "token"}, + sprites_mod: FakeSpritesMod, + retry_backoffs_ms: [0, 0] + ) + + assert result.teardown_verified == false + assert result.teardown_attempts == 2 + assert_warning_contains(result.warnings, "session_stop_failed={:error, :already_stopped}") + assert_warning_contains(result.warnings, "sprite_destroy_failed={:error, :denied}") + assert_warning_contains(result.warnings, "sprite_destroy_failed={:error, :still_denied}") + assert_warning_contains(result.warnings, "sprite teardown not verified after retries") + end + + test "teardown/2 reports missing sprite name" do + Process.put(:sprite_agent_stop_result, :ok) + + result = + SpriteLifecycle.teardown( + "sess-t4", + sprite_name: nil, + stop_mod: FakeAgentMod, + sprites_mod: FakeSpritesMod, + retry_backoffs_ms: [0] + ) + + assert result.teardown_verified == false + assert result.teardown_attempts == 1 + assert_warning_contains(result.warnings, "sprite_verification_failed=:missing_sprite_name") + end + + test "teardown/2 reports missing sprites client when token is blank" do + Process.put(:sprite_agent_stop_result, :ok) + + result = + SpriteLifecycle.teardown( + "sess-t5", + sprite_name: "sprite-5", + stop_mod: FakeAgentMod, + sprite_config: %{token: " "}, + sprites_mod: FakeSpritesMod, + retry_backoffs_ms: [0] + ) + + assert result.teardown_verified == false + assert_warning_contains(result.warnings, "sprite_verification_failed=:missing_sprites_client") + end + + test "teardown/2 reports missing get_sprite API" do + Process.put(:sprite_agent_stop_result, :ok) + + result = + SpriteLifecycle.teardown( + "sess-t6", + sprite_name: "sprite-6", + stop_mod: FakeAgentMod, + sprite_config: %{token: "token"}, + sprites_mod: FakeSpritesNoGet, + retry_backoffs_ms: [0] + ) + + assert result.teardown_verified == false + assert_warning_contains(result.warnings, "sprite_verification_failed=:missing_get_sprite_api") + end + + test "teardown/2 reports missing destroy API" do + Process.put(:sprite_agent_stop_result, :ok) + + Process.put({:sprites_get_script, FakeSpritesNoDestroy}, [ + {:ok, %{id: "sprite-7"}}, + {:ok, %{id: "sprite-7"}} + ]) + + result = + SpriteLifecycle.teardown( + "sess-t7", + sprite_name: "sprite-7", + stop_mod: FakeAgentMod, + sprite_config: %{token: "token"}, + sprites_mod: FakeSpritesNoDestroy, + retry_backoffs_ms: [0] + ) + + assert result.teardown_verified == false + assert_warning_contains(result.warnings, "sprite_destroy_failed={:error, :missing_destroy_api}") + end + + defp assert_warning_contains(warnings, expected_fragment) do + assert is_list(warnings) + + assert Enum.any?(warnings, fn warning -> + String.contains?(warning, expected_fragment) + end) + end +end diff --git a/test/jido/shell/stream_json_test.exs b/test/jido/shell/stream_json_test.exs new file mode 100644 index 0000000..cc1db82 --- /dev/null +++ b/test/jido/shell/stream_json_test.exs @@ -0,0 +1,312 @@ +defmodule Jido.Shell.StreamJsonTest do + use ExUnit.Case, async: true + + alias Jido.Shell.StreamJson + + defmodule FakeShellAgent do + def run(session_id, command, opts) do + send(self(), {:shell_run, session_id, command, opts}) + Process.get(:stream_shell_result, {:ok, ""}) + end + end + + defmodule FakeSessionServer do + def subscribe(session_id, pid) do + Process.put({:stream_subscriber, session_id}, pid) + {:ok, :subscribed} + end + + def unsubscribe(_session_id, _pid) do + Process.put(:stream_unsubscribed, true) + {:ok, :unsubscribed} + end + + def run_command(session_id, command, opts) do + send(self(), {:run_command, session_id, command, opts}) + + case Process.get(:stream_run_result, {:ok, :accepted}) do + {:ok, :accepted} = accepted -> + subscriber = Process.get({:stream_subscriber, session_id}, self()) + + Process.get(:stream_events, []) + |> Enum.each(fn + {:after, ms, message} -> Process.send_after(subscriber, message, ms) + message -> send(subscriber, message) + end) + + accepted + + other -> + other + end + end + end + + defmodule UnsupportedSessionServer do + end + + test "streams JSON output and raw lines via ShellSessionServer" do + session_id = "sess-stream-1" + + Process.put(:stream_events, [ + shell_event(session_id, :noise), + shell_event(session_id, {:cwd_changed, "/tmp"}), + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, {:output, "{\"a\":1}\nraw "}), + shell_event(session_id, {:output, "line\n{\"b\":2}\n"}), + shell_event(session_id, :command_done) + ]) + + on_mode = fn mode -> send(self(), {:mode, mode}) end + on_event = fn event -> send(self(), {:event, event}) end + on_raw_line = fn line -> send(self(), {:raw, line}) end + + assert {:ok, output, events} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/work/o'hare", + "echo stream", + on_mode: on_mode, + on_event: on_event, + on_raw_line: on_raw_line, + timeout: 1_000, + heartbeat_interval_ms: 50 + ) + + assert output == "{\"a\":1}\nraw line\n{\"b\":2}" + assert events == [%{"a" => 1}, %{"b" => 2}] + + assert_receive {:mode, "session_server_stream"} + assert_receive {:event, %{"a" => 1}} + assert_receive {:event, %{"b" => 2}} + assert_receive {:raw, "raw line"} + + assert_receive {:run_command, ^session_id, wrapped, [execution_context: %{max_runtime_ms: 1_000}]} + assert wrapped == "cd '/work/o'\\''hare' && echo stream" + assert Process.get(:stream_unsubscribed) == true + end + + test "parses trailing JSON without newline on command completion" do + session_id = "sess-stream-tail" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, {:output, "{\"tail\":1}"}), + shell_event(session_id, :command_done) + ]) + + assert {:ok, "{\"tail\":1}", [%{"tail" => 1}]} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo tail", + timeout: 500, + heartbeat_interval_ms: 50 + ) + end + + test "returns cancelled error when command is cancelled" do + session_id = "sess-stream-cancel" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, :command_cancelled) + ]) + + assert {:error, :cancelled} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo cancel", + timeout: 500 + ) + end + + test "returns command crash reason when command crashes" do + session_id = "sess-stream-crash" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, {:command_crashed, :boom}) + ]) + + assert {:error, {:command_crashed, :boom}} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo crash", + timeout: 500 + ) + end + + test "returns shell error events as errors" do + session_id = "sess-stream-error" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, {:error, :bad_output}) + ]) + + assert {:error, :bad_output} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo error", + timeout: 500 + ) + end + + test "emits heartbeat callbacks while idle and before completion" do + session_id = "sess-stream-heartbeat" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + {:after, 35, shell_event(session_id, :command_done)} + ]) + + on_heartbeat = fn idle_ms -> send(self(), {:heartbeat, idle_ms}) end + + assert {:ok, "", []} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo hb", + timeout: 200, + heartbeat_interval_ms: 10, + on_heartbeat: on_heartbeat + ) + + assert_receive {:heartbeat, idle_ms} + assert idle_ms >= 10 + end + + test "times out when stream never completes" do + session_id = "sess-stream-timeout" + Process.put(:stream_events, [shell_event(session_id, {:command_started, "ignored"})]) + + assert {:error, :timeout} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo timeout", + timeout: 20, + heartbeat_interval_ms: 5 + ) + end + + test "falls back to shell agent when session server is unsupported" do + Process.put(:stream_shell_result, {:ok, "{\"x\":1}\nraw\n"}) + + on_mode = fn mode -> send(self(), {:mode, mode}) end + on_event = fn event -> send(self(), {:event, event}) end + on_raw_line = fn line -> send(self(), {:raw, line}) end + + assert {:ok, output, events} = + StreamJson.run( + FakeShellAgent, + UnsupportedSessionServer, + "sess-fallback-1", + "/tmp/fallback", + "echo fallback", + on_mode: on_mode, + on_event: on_event, + on_raw_line: on_raw_line, + timeout: 1_500 + ) + + assert output == "{\"x\":1}\nraw" + assert events == [%{"x" => 1}] + + assert_receive {:mode, "session_server_stream"} + assert_receive {:mode, "shell_agent_fallback"} + assert_receive {:event, %{"x" => 1}} + assert_receive {:raw, "raw"} + assert_receive {:shell_run, "sess-fallback-1", "cd '/tmp/fallback' && echo fallback", [timeout: 1_500]} + end + + test "does not fall back when custom fallback eligibility rejects the reason" do + fallback_eligible? = fn _ -> false end + + assert {:error, :unsupported_shell_session_server} = + StreamJson.run( + FakeShellAgent, + UnsupportedSessionServer, + "sess-fallback-no", + "/tmp", + "echo no", + fallback_eligible?: fallback_eligible? + ) + + refute_receive {:shell_run, _, _, _}, 20 + end + + test "handles fallback eligibility callback failures safely" do + fallback_eligible? = fn _ -> raise "callback failure" end + + assert {:error, :unsupported_shell_session_server} = + StreamJson.run( + FakeShellAgent, + UnsupportedSessionServer, + "sess-fallback-raise", + "/tmp", + "echo no", + fallback_eligible?: fallback_eligible? + ) + end + + test "can fallback from streaming run_command errors when explicitly allowed" do + Process.put(:stream_run_result, {:error, :disconnected}) + Process.put(:stream_shell_result, {:ok, "{\"ok\":true}\n"}) + + assert {:ok, "{\"ok\":true}", [%{"ok" => true}]} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + "sess-fallback-run-error", + "/tmp", + "echo ok", + fallback_eligible?: fn :disconnected -> true end + ) + end + + test "swallows callback failures" do + session_id = "sess-callback-safe" + + Process.put(:stream_events, [ + shell_event(session_id, {:command_started, "ignored"}), + shell_event(session_id, {:output, "{\"safe\":true}\nraw\n"}), + shell_event(session_id, :command_done) + ]) + + assert {:ok, _output, [%{"safe" => true}]} = + StreamJson.run( + FakeShellAgent, + FakeSessionServer, + session_id, + "/tmp", + "echo safe", + on_mode: fn _ -> raise "mode callback" end, + on_event: fn _ -> raise "event callback" end, + on_raw_line: fn _ -> raise "raw callback" end, + on_heartbeat: fn _ -> raise "heartbeat callback" end, + heartbeat_interval_ms: 5, + timeout: 200 + ) + end + + defp shell_event(session_id, event), do: {:jido_shell_session, session_id, event} +end