429 lines
12 KiB
Elixir
429 lines
12 KiB
Elixir
defmodule CljElixir.NReplTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
alias CljElixir.NRepl.{Bencode, Server, SessionManager, Handler}
|
|
|
|
# --- Bencode Tests ---
|
|
|
|
describe "Bencode encoding" do
|
|
test "encode string" do
|
|
assert Bencode.encode("hello") == "5:hello"
|
|
assert Bencode.encode("") == "0:"
|
|
end
|
|
|
|
test "encode integer" do
|
|
assert Bencode.encode(42) == "i42e"
|
|
assert Bencode.encode(0) == "i0e"
|
|
assert Bencode.encode(-7) == "i-7e"
|
|
end
|
|
|
|
test "encode list" do
|
|
assert Bencode.encode([1, 2, 3]) == "li1ei2ei3ee"
|
|
assert Bencode.encode([]) == "le"
|
|
assert Bencode.encode(["hello", 42]) == "l5:helloi42ee"
|
|
end
|
|
|
|
test "encode dict" do
|
|
assert Bencode.encode(%{"op" => "eval"}) == "d2:op4:evale"
|
|
end
|
|
|
|
test "encode nested" do
|
|
msg = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "1"}
|
|
encoded = Bencode.encode(msg)
|
|
assert is_binary(encoded)
|
|
assert String.starts_with?(encoded, "d")
|
|
assert String.ends_with?(encoded, "e")
|
|
end
|
|
|
|
test "encode atom keys" do
|
|
assert Bencode.encode(%{op: "eval"}) == "d2:op4:evale"
|
|
end
|
|
end
|
|
|
|
describe "Bencode decoding" do
|
|
test "decode string" do
|
|
assert Bencode.decode("5:hello") == "hello"
|
|
assert Bencode.decode("0:") == ""
|
|
end
|
|
|
|
test "decode integer" do
|
|
assert Bencode.decode("i42e") == 42
|
|
assert Bencode.decode("i-7e") == -7
|
|
end
|
|
|
|
test "decode list" do
|
|
assert Bencode.decode("li1ei2ei3ee") == [1, 2, 3]
|
|
end
|
|
|
|
test "decode dict" do
|
|
result = Bencode.decode("d2:op4:evale")
|
|
assert result == %{"op" => "eval"}
|
|
end
|
|
|
|
test "roundtrip" do
|
|
original = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "msg-1"}
|
|
assert Bencode.decode(Bencode.encode(original)) == original
|
|
end
|
|
|
|
test "roundtrip nested" do
|
|
original = %{"ops" => %{"eval" => %{}, "clone" => %{}}, "status" => ["done"]}
|
|
assert Bencode.decode(Bencode.encode(original)) == original
|
|
end
|
|
end
|
|
|
|
# --- Session Manager Tests ---
|
|
|
|
describe "SessionManager" do
|
|
setup do
|
|
{:ok, manager} = SessionManager.start_link()
|
|
%{manager: manager}
|
|
end
|
|
|
|
test "create session", %{manager: manager} do
|
|
id = SessionManager.create_session(manager)
|
|
assert is_binary(id)
|
|
assert String.length(id) > 0
|
|
end
|
|
|
|
test "list sessions", %{manager: manager} do
|
|
assert SessionManager.list_sessions(manager) == []
|
|
id = SessionManager.create_session(manager)
|
|
assert SessionManager.list_sessions(manager) == [id]
|
|
end
|
|
|
|
test "eval in session", %{manager: manager} do
|
|
id = SessionManager.create_session(manager)
|
|
assert {:ok, "3"} = SessionManager.eval(manager, id, "(+ 1 2)")
|
|
end
|
|
|
|
test "session state persists", %{manager: manager} do
|
|
id = SessionManager.create_session(manager)
|
|
|
|
{:ok, _} =
|
|
SessionManager.eval(manager, id, """
|
|
(defmodule NReplSessionTest
|
|
(defn hello [] :hi))
|
|
""")
|
|
|
|
{:ok, result} = SessionManager.eval(manager, id, "(NReplSessionTest/hello)")
|
|
assert result == ":hi"
|
|
end
|
|
|
|
test "close session", %{manager: manager} do
|
|
id = SessionManager.create_session(manager)
|
|
assert :ok = SessionManager.close_session(manager, id)
|
|
assert SessionManager.list_sessions(manager) == []
|
|
end
|
|
|
|
test "independent sessions", %{manager: manager} do
|
|
id1 = SessionManager.create_session(manager)
|
|
id2 = SessionManager.create_session(manager)
|
|
assert id1 != id2
|
|
{:ok, "3"} = SessionManager.eval(manager, id1, "(+ 1 2)")
|
|
{:ok, "7"} = SessionManager.eval(manager, id2, "(+ 3 4)")
|
|
end
|
|
end
|
|
|
|
# --- Handler Tests ---
|
|
|
|
describe "Handler" do
|
|
setup do
|
|
{:ok, manager} = SessionManager.start_link()
|
|
%{manager: manager}
|
|
end
|
|
|
|
test "clone creates session", %{manager: manager} do
|
|
[response] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
assert response["status"] == ["done"]
|
|
assert is_binary(response["new-session"])
|
|
end
|
|
|
|
test "describe returns ops", %{manager: manager} do
|
|
[response] = Handler.handle(%{"op" => "describe", "id" => "1"}, manager)
|
|
assert response["status"] == ["done"]
|
|
assert is_map(response["ops"])
|
|
assert Map.has_key?(response["ops"], "eval")
|
|
assert Map.has_key?(response["ops"], "clone")
|
|
end
|
|
|
|
test "eval returns value", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(+ 1 2)",
|
|
"session" => session,
|
|
"id" => "2"
|
|
},
|
|
manager
|
|
)
|
|
|
|
# Should have value response and done response
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
dones = Enum.filter(responses, fn r -> r["status"] == ["done"] end)
|
|
|
|
assert length(values) == 1
|
|
assert hd(values)["value"] == "3"
|
|
assert length(dones) == 1
|
|
end
|
|
|
|
test "eval captures stdout", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(println \"hello from nrepl\")",
|
|
"session" => session,
|
|
"id" => "2"
|
|
},
|
|
manager
|
|
)
|
|
|
|
outs = Enum.filter(responses, &Map.has_key?(&1, "out"))
|
|
assert length(outs) >= 1
|
|
assert hd(outs)["out"] =~ "hello from nrepl"
|
|
end
|
|
|
|
test "eval error", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(defmodule HandlerErrTest (bad-syntax",
|
|
"session" => session,
|
|
"id" => "2"
|
|
},
|
|
manager
|
|
)
|
|
|
|
errs = Enum.filter(responses, &Map.has_key?(&1, "err"))
|
|
assert length(errs) >= 1
|
|
end
|
|
|
|
test "ls-sessions", %{manager: manager} do
|
|
[clone1] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
[clone2] = Handler.handle(%{"op" => "clone", "id" => "2"}, manager)
|
|
|
|
[response] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager)
|
|
sessions = response["sessions"]
|
|
|
|
assert length(sessions) == 2
|
|
assert clone1["new-session"] in sessions
|
|
assert clone2["new-session"] in sessions
|
|
end
|
|
|
|
test "close session", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
[close_resp] =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "close",
|
|
"session" => session,
|
|
"id" => "2"
|
|
},
|
|
manager
|
|
)
|
|
|
|
assert close_resp["status"] == ["done"]
|
|
|
|
[ls_resp] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager)
|
|
assert ls_resp["sessions"] == []
|
|
end
|
|
|
|
test "unknown op", %{manager: manager} do
|
|
[response] = Handler.handle(%{"op" => "bogus", "id" => "1"}, manager)
|
|
assert "unknown-op" in response["status"]
|
|
end
|
|
|
|
test "eval returns actual ns after ns declaration", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
# Default ns is "user"
|
|
responses =
|
|
Handler.handle(
|
|
%{"op" => "eval", "code" => "(+ 1 2)", "session" => session, "id" => "2"},
|
|
manager
|
|
)
|
|
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
assert hd(values)["ns"] == "user"
|
|
|
|
# After ns declaration, ns should update
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(ns NReplNsTest)\n(defn hi [] :hey)",
|
|
"session" => session,
|
|
"id" => "3"
|
|
},
|
|
manager
|
|
)
|
|
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
assert hd(values)["ns"] == "NReplNsTest"
|
|
end
|
|
|
|
test "incremental def in ns via nrepl", %{manager: manager} do
|
|
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
|
session = clone_resp["new-session"]
|
|
|
|
# Set up namespace
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(ns NReplIncrTest)\n(defn foo [] :original)",
|
|
"session" => session,
|
|
"id" => "2"
|
|
},
|
|
manager
|
|
)
|
|
|
|
# Add a new function without ns
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(defn bar [] :added)",
|
|
"session" => session,
|
|
"id" => "3"
|
|
},
|
|
manager
|
|
)
|
|
|
|
# Both should work
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(NReplIncrTest/bar)",
|
|
"session" => session,
|
|
"id" => "4"
|
|
},
|
|
manager
|
|
)
|
|
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
assert hd(values)["value"] == ":added"
|
|
|
|
responses =
|
|
Handler.handle(
|
|
%{
|
|
"op" => "eval",
|
|
"code" => "(NReplIncrTest/foo)",
|
|
"session" => session,
|
|
"id" => "5"
|
|
},
|
|
manager
|
|
)
|
|
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
assert hd(values)["value"] == ":original"
|
|
end
|
|
end
|
|
|
|
# --- TCP Integration Test ---
|
|
|
|
describe "TCP server" do
|
|
test "server starts and accepts connections" do
|
|
{:ok, server} = Server.start_link(port: 0)
|
|
port = Server.port(server)
|
|
assert port > 0
|
|
|
|
# Connect as a client
|
|
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false])
|
|
|
|
# Send clone request
|
|
clone_msg = Bencode.encode(%{"op" => "clone", "id" => "1"})
|
|
:ok = :gen_tcp.send(socket, clone_msg)
|
|
|
|
# Read response
|
|
{:ok, data} = :gen_tcp.recv(socket, 0, 5000)
|
|
response = Bencode.decode(data)
|
|
|
|
assert response["status"] == ["done"]
|
|
assert is_binary(response["new-session"])
|
|
|
|
:gen_tcp.close(socket)
|
|
GenServer.stop(server)
|
|
end
|
|
|
|
test "full eval over TCP" do
|
|
{:ok, server} = Server.start_link(port: 0)
|
|
port = Server.port(server)
|
|
|
|
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false])
|
|
|
|
# Clone
|
|
:ok = :gen_tcp.send(socket, Bencode.encode(%{"op" => "clone", "id" => "1"}))
|
|
{:ok, clone_data} = :gen_tcp.recv(socket, 0, 5000)
|
|
clone_resp = Bencode.decode(clone_data)
|
|
session = clone_resp["new-session"]
|
|
|
|
# Eval
|
|
eval_msg =
|
|
Bencode.encode(%{
|
|
"op" => "eval",
|
|
"code" => "(+ 21 21)",
|
|
"session" => session,
|
|
"id" => "2"
|
|
})
|
|
|
|
:ok = :gen_tcp.send(socket, eval_msg)
|
|
|
|
# Read all responses (value + done)
|
|
responses = read_all_responses(socket)
|
|
|
|
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
|
assert length(values) >= 1
|
|
assert hd(values)["value"] == "42"
|
|
|
|
:gen_tcp.close(socket)
|
|
GenServer.stop(server)
|
|
end
|
|
end
|
|
|
|
# Helper to read multiple bencode responses
|
|
defp read_all_responses(socket, acc \\ [], buffer \\ "") do
|
|
case :gen_tcp.recv(socket, 0, 2000) do
|
|
{:ok, data} ->
|
|
new_buffer = buffer <> data
|
|
{msgs, rest} = decode_available(new_buffer)
|
|
new_acc = acc ++ msgs
|
|
|
|
if Enum.any?(new_acc, fn r -> r["status"] == ["done"] end) do
|
|
new_acc
|
|
else
|
|
read_all_responses(socket, new_acc, rest)
|
|
end
|
|
|
|
{:error, :timeout} ->
|
|
{msgs, _rest} = decode_available(buffer)
|
|
acc ++ msgs
|
|
|
|
{:error, _} ->
|
|
acc
|
|
end
|
|
end
|
|
|
|
defp decode_available(data, acc \\ []) do
|
|
try do
|
|
{msg, rest} = Bencode.decode_one(data)
|
|
decode_available(rest, acc ++ [msg])
|
|
rescue
|
|
_ -> {acc, data}
|
|
end
|
|
end
|
|
end
|