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