Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
54 changes: 54 additions & 0 deletions test/jido/shell/exec_test.exs
Original file line number Diff line number Diff line change
@@ -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
314 changes: 314 additions & 0 deletions test/jido/shell/sprite_lifecycle_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading