From d3ea64cc15518c8a92ee9e3bf8b437eb29074dd4 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:43:34 -0500 Subject: [PATCH] Add infrastructure for JS testing --- config/test.exs | 3 + js/index.mjs | 14 +++ lib/beam_js.ex | 134 ++------------------------ lib/beam_js/boxed.ex | 3 + lib/beam_js/encoder.ex | 191 ++++++++++++++++++++++++++++++++++++++ lib/beam_js/function.ex | 21 +++++ lib/beam_js/js_runtime.ex | 53 +++++++++++ lib/beam_js/module.ex | 32 +++++++ lib/beam_js/utils.ex | 4 + mix.exs | 4 + mix.lock | 3 + test/beam_js_test.exs | 17 +++- test/support/example.ex | 5 + test/support/js_case.ex | 14 +++ test/test_helper.exs | 1 + 15 files changed, 368 insertions(+), 131 deletions(-) create mode 100644 config/test.exs create mode 100644 js/index.mjs create mode 100644 lib/beam_js/boxed.ex create mode 100644 lib/beam_js/encoder.ex create mode 100644 lib/beam_js/function.ex create mode 100644 lib/beam_js/js_runtime.ex create mode 100644 lib/beam_js/module.ex create mode 100644 lib/beam_js/utils.ex create mode 100644 test/support/example.ex create mode 100644 test/support/js_case.ex diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..a59b9c0 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,3 @@ +import Config + +config :nodejs, debug_mode: true diff --git a/js/index.mjs b/js/index.mjs new file mode 100644 index 0000000..3a6fae0 --- /dev/null +++ b/js/index.mjs @@ -0,0 +1,14 @@ +class Boxed { + constructor(type, value) { + this.type = type; + this.value = value; + } +} + +export const boxed = (type, value) => { + return new Boxed(type, value); +}; + +export default { + boxed, +}; diff --git a/lib/beam_js.ex b/lib/beam_js.ex index bc3d615..08eaf8f 100644 --- a/lib/beam_js.ex +++ b/lib/beam_js.ex @@ -2,132 +2,10 @@ defmodule BeamJs do @moduledoc """ Documentation for `BeamJs`. """ - require Logger - - def module_code(module) do - module - |> BeamFile.byte_code() - |> ok(fn {_, _, _, _, _, sections} -> - {:ok, Enum.filter(sections, fn - {:function, _fun_name, _arity, _, _code} -> - true - _other -> - false - end)} - end) - |> ok(fn functions -> - Enum.map(functions, fn {:function, name, arity, _, code} -> - {"#{name}/#{arity}", transform_function_code(code)} - end) - |> Map.new() - end) - end - - def transform_function_code(ops) do - Enum.reduce(ops, [], fn - {:label, number}, [{line_no, head_ops} | tail] -> - [{number, []} | [{line_no, Enum.reverse(head_ops)} | tail]] - - {:label, number}, acc -> - [{number, []} | acc] - - {:line, _line_number}, [] -> - [] - - other_op, [{number, head} | tail] -> - [{number, [other_op|head]} | tail] - end) - |> Enum.reverse() - end - - def encode_fun(label_sections) do - "{\n" <> Enum.map_join(label_sections, ",\n", fn {label, ops} -> - body = Enum.map_join(ops, "", &encode_op/1) - " #{label}: (p) => {\n#{body}}" - end) <> "}" - end - - def encode_op(tuple) when is_tuple(tuple) do - op_name = elem(tuple, 0) - args = - try do - encode_args(tuple) - rescue - e -> - Logger.error("Error encoding op: #{inspect(tuple)}") - reraise e, __STACKTRACE__ - end - - " BeamJs.Ops.#{op_name}(p, " <> Enum.join(args, ", ") <> ");\n" - end - - def encode_op(atom) when is_atom(atom), do: " BeamJs.Ops.#{atom}(p);\n" - - def encode_args({:allocate, frame_size, live}), do: [number(frame_size), number(live)] - def encode_args({:line, number}), do: [number(number)] - def encode_args({:func_info, {:atom, m}, {:atom, f}, arity}), do: [quoted_string(m), quoted_string(f), number(arity)] - def encode_args({:select_val, source, {:f, fail_label}, comparison}), do: [encode_source(source), number(fail_label), encode_value(comparison)] - def encode_args({:move, source, {register_bank, number}}) when register_bank in [:x, :y], do: [encode_source(source), quoted_string(register_bank), number(number)] - def encode_args({:call, _live, {_m, f, a}}), do: [quoted_string(f), number(a)] - def encode_args({:call_ext_only, _live, {:extfunc, m, f, a}}), do: [quoted_string(m), quoted_string(f), number(a)] - def encode_args({:call_only, _live, {_m, f, a}}), do: [quoted_string(f), number(a)] - def encode_args({:deallocate, live}), do: [number(live)] - def encode_args({:gc_bif, bif, {:f, fail_to}, _unknown, args, {register_bank, number}}) when register_bank in [:x, :y] do - [ - quoted_string(bif), - number(fail_to), - "[" <> (args |> Enum.map(&encode_source/1) |> Enum.join(", ")) <> "]", - register_bank, - number - ] - end - - def encode_source({:tr, source, _}), do: encode_source(source) - def encode_source({:x, number}), do: "p.registers.x[#{number}]" - def encode_source({:y, number}), do: "p.registers.y[#{number}]" - def encode_source({:integer, number}), do: number(number) - def encode_source(value), do: encode_value(value) - - - def encode_value({:literal, value}), do: encode_value(value) - - def encode_value({type, value}) when type in [:atom, :x, :y, :list] do - ~s|BeamJs.boxed("#{type}", #{encode_value(value)})| - end - - def encode_value(tuple) when is_tuple(tuple) do - ~s|BeamJs.boxed("tuple", #{encode_value(Tuple.to_list(tuple))})| - end - - def encode_value(value) when is_binary(value) do - if String.valid?(value) do - quoted_string(value) - else - # Likely a raw bytes array - base64 = value |> Base.encode64() |> quoted_string() - ~s|atob(#{base64})| - end - end - - def encode_value(value) when is_list(value) do - ~s|[#{Enum.map_join(value, ", ", &encode_value/1)}]| - end - - def encode_value(value) when is_atom(value) do - quoted_string(value) - end - - def encode_value(value) when is_number(value) do - "#{value}" - end - - defp quoted_string(v), do: ~s|"#{v}"| - defp number(n), do: "#{n}" - - def fact(0), do: 1 - def fact(1), do: 1 - def fact(n), do: fact(n - 1) + fact(n - 2) - - defp ok({:ok, value}, fun), do: fun.(value) - defp ok(other, _fun), do: other +end + +defimpl JSON.Encoder, for: BeamJs.Boxed do + def encode(value, encoder) do + ["BeamJs.boxed(", encoder.(value.type, encoder), ", ", encoder.(value.value, encoder),")"] + end end diff --git a/lib/beam_js/boxed.ex b/lib/beam_js/boxed.ex new file mode 100644 index 0000000..a2d2660 --- /dev/null +++ b/lib/beam_js/boxed.ex @@ -0,0 +1,3 @@ + defmodule BeamJs.Boxed do + defstruct [:type, :value] + end diff --git a/lib/beam_js/encoder.ex b/lib/beam_js/encoder.ex new file mode 100644 index 0000000..99cba9b --- /dev/null +++ b/lib/beam_js/encoder.ex @@ -0,0 +1,191 @@ +defmodule BeamJs.Encoder do + require Logger + + alias BeamJs.Boxed + + def encode_module(mod_ast) do + "import BeamJs from '../js/index.mjs';\n" <> + ( + mod_ast + |> Enum.map_join("\n", fn {fn_name, ops} -> + encode_fun_to_js_export(ops, fn_name) + end) + ) + end + + def encode_fun_to_js_value(label_sections) do + Enum.map(label_sections, fn {label, ops} -> + body = Enum.map(ops, &encode_op/1) + [label, body] + end) + |> JSON.encode!() + end + + def encode_fun_to_js_export(label_sections, function_name) do + label_sections + |> encode_fun_to_js_value() + |> then(&"export const #{function_name} = #{&1};") + end + + def encode_op(tuple) when is_tuple(tuple) do + op_name = elem(tuple, 0) + args = + try do + encode_op_args(tuple) + rescue + e -> + Logger.error("Error encoding op: #{inspect(tuple)}") + reraise e, __STACKTRACE__ + end + + [op_name | args] + end + + def encode_op(atom) when is_atom(atom), do: [atom] + + def encode_op_args(op) + def encode_op_args({:allocate, frame_size, live}), do: [frame_size, live] + def encode_op_args({:allocate_heap, n, {:alloc, list}, n2}) do + [ + n, + Enum.map(list, &encode_value/1), + n2 + ] + end + def encode_op_args({:bs_create_bin, {:f, fail_to}, alloc, live, unit, {dest_bank, dest_number}, {:list, segments}}) do + [ + fail_to, + alloc, + live, + unit, + dest_bank, + dest_number, + Enum.map(segments, &encode_value/1) + ] + end + def encode_op_args({:line, number}), do: [number] + def encode_op_args({:func_info, {:atom, m}, {:atom, f}, arity}), do: [Atom.to_string(m), Atom.to_string(f), arity] + def encode_op_args({:make_fun3, {m, f, a}, x, y, {dest_bank, dest_number}, {:list, list}}) do + [ + m, + f, + a, + x, + y, + dest_bank, + dest_number, + Enum.map(list, &encode_value/1) + ] + end + def encode_op_args({:select_val, source, {:f, fail_label}, {:list, f_list}}), do: [encode_source(source), fail_label, encode_f_list(f_list)] + def encode_op_args({:move, source, dest}), do: [encode_source(source), encode_source(dest)] + def encode_op_args({:call, _live, {_m, f, a}}), do: [f, a] + def encode_op_args({:call_ext, _live, {:extfunc, m, f, a}}), do: [m, f, a] + def encode_op_args({:call_ext_only, _live, {:extfunc, m, f, a}}), do: [m, f, a] + def encode_op_args({:call_only, _live, {_m, f, a}}), do: [f, a] + def encode_op_args({:deallocate, live}), do: [live] + def encode_op_args({:gc_bif, bif, {:f, fail_to}, _unknown, args, {register_bank, number}}) when register_bank in [:x, :y] do + [ + bif, + fail_to, + Enum.map(args, &encode_source/1), + register_bank, + number + ] + end + def encode_op_args({:get_tuple_element, {source_bank, source_number}, index, {dest_bank, dest_number}}) when source_bank in [:x, :y] and dest_bank in [:x, :y] do + [ + source_bank, + source_number, + index, + dest_bank, + dest_number + ] + end + def encode_op_args({:init_yregs, {:list, sources}}), do: [Enum.map(sources, &encode_source/1)] + def encode_op_args({:jump, {:f, to}}), do: [to] + def encode_op_args({:put_list, {bank_a, number_a}, {bank_b, number_b}, {bank_c, number_c}}), do: [bank_a, number_a, bank_b, number_b, bank_c, number_c] + def encode_op_args({:put_list, {bank_a, number_a}, nil, {bank_c, number_c}}), do: [bank_a, number_a, nil, nil, bank_c, number_c] + def encode_op_args({:bif, bif, {:f, fail_to}, args, {register_bank, number}}) when register_bank in [:x, :y] do + [ + bif, + fail_to, + Enum.map(args, &encode_source/1), + register_bank, + number + ] + end + def encode_op_args({:select_tuple_arity, {:tr, {source_bank, source_number}, {:t_tuple, 0, false, %{}}}, {:f, fail_to}, + {:list, f_list}}) do + [ + source_bank, + source_number, + fail_to, + encode_f_list(f_list) + ] + end + def encode_op_args({:swap, {a_bank, a_n}, {b_bank, b_n}}), do: [a_bank, a_n, b_bank, b_n] + def encode_op_args({:test, test_name, {:f, fail_to}, args}) do + [ + test_name, + fail_to, + Enum.map(args, &encode_source/1) + ] + end + def encode_op_args({:test_heap, {:alloc, list}, b}), do: [Enum.map(list, &encode_value/1), b] + def encode_op_args({:test_heap, a, b}), do: [a, b] + def encode_op_args({:trim, n, remaining}), do: [n, remaining] + def encode_op_args({:try, {register_bank, number}, {:f, fail_to}}) when register_bank in [:x, :y], do: [register_bank, number, fail_to] + def encode_op_args({:try_case, {register_bank, number}}) when register_bank in [:x, :y], do: [register_bank, number] + def encode_op_args({:try_end, {register_bank, number}}) when register_bank in [:x, :y], do: [register_bank, number] + + def encode_f_list(comparison) do + comparison + |> Enum.chunk_every(2) + |> Enum.map(fn [value, {:f, number}] -> + [encode_value(value), number] + end) + end + + def encode_source({:tr, source, _}), do: encode_source(source) + def encode_source({:x, number}), do: [:x, number] + def encode_source({:y, number}), do: [:y, number] + def encode_source(value), do: encode_value(value) + + + def encode_value({:literal, value}), do: encode_value(value) + + def encode_value({type, value}) when type in [:atom, :list, :integer] do + %Boxed{type: type, value: value} + end + + def encode_value(tuple) when is_tuple(tuple) do + %Boxed{type: "tuple", value: encode_value(Tuple.to_list(tuple))} + end + + def encode_value(value) when is_binary(value) do + if String.valid?(value) do + value + else + # Likely a raw bytes array + base64 = value |> Base.encode64() |> quoted_string() + ~s|atob(#{base64})| + end + end + + def encode_value(value) when is_list(value) do + Enum.map(value, &encode_value/1) + end + + def encode_value(value) when is_atom(value) do + %Boxed{type: :atom, value: value} + end + + def encode_value(value) when is_integer(value) do + %Boxed{type: :integer, value: value} + end + + def encode_value(map) when is_map(map), do: %Boxed{type: :map, value: map |> Enum.map(fn {key, value} -> {encode_value(key), encode_value(value)} end) |> Map.new()} + + defp quoted_string(v), do: ~s|"#{v}"| +end diff --git a/lib/beam_js/function.ex b/lib/beam_js/function.ex new file mode 100644 index 0000000..fe24a19 --- /dev/null +++ b/lib/beam_js/function.ex @@ -0,0 +1,21 @@ +defmodule BeamJs.Function do + def to_ast(ops) do + Enum.reduce(ops, [], fn + {:label, number}, [{line_no, head_ops} | tail] -> + [{number, []} | [{line_no, Enum.reverse(head_ops)} | tail]] + + {:label, number}, acc -> + [{number, []} | acc] + + {:line, _line_number}, [] -> + [] + + other_op, [{number, head} | tail] -> + [{number, [other_op|head]} | tail] + end) + |> then(fn [{label, head_ops}|tail] -> + [{label, Enum.reverse(head_ops)}|tail] + end) + |> Enum.reverse() + end +end diff --git a/lib/beam_js/js_runtime.ex b/lib/beam_js/js_runtime.ex new file mode 100644 index 0000000..405ff96 --- /dev/null +++ b/lib/beam_js/js_runtime.ex @@ -0,0 +1,53 @@ +defmodule BeamJs.JsRuntime do + use GenServer + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def run(pid, code) do + GenServer.call(pid, {:run, code}) + end + + @impl GenServer + def init(_opts) do + path = System.find_executable("node") + port = Port.open({:spawn_executable, path}, [:binary, args: ["-i"]]) + wait_for_prompt() + + {:ok, %{port: port}} + end + + @impl GenServer + def handle_call({:run, code}, _from, %{port: port} = state) do + send(port, {self(), {:command, code <> "\r\n"}}) + result = + receive do + {_port, {:data, msg}} -> msg + end + + result = + result + |> String.trim_trailing("\n> ") + |> String.trim("'") + case JSON.decode(result) do + {:ok, result} -> + {:reply, {:ok, result}, state} + {:error, _decode_error} -> + {:reply, {:error, result}, state} + end + end + + @impl GenServer + def terminate(_reason, %{port: port}) do + Port.close(port) + end + + defp wait_for_prompt do + receive do + {_port, {:data, "> "}} -> :ok + {_port, {:data, _other}} -> + wait_for_prompt() + end + end +end diff --git a/lib/beam_js/module.ex b/lib/beam_js/module.ex new file mode 100644 index 0000000..04aa69d --- /dev/null +++ b/lib/beam_js/module.ex @@ -0,0 +1,32 @@ +defmodule BeamJs.Module do + import BeamJs.Utils + + alias BeamJs.Function + + def to_ast(module, opts \\ []) do + function_map = + module + |> BeamFile.byte_code() + |> ok(fn {_, _, _, _, _, sections} -> + {:ok, Enum.filter(sections, fn + {:function, _fun_name, _arity, _, _code} -> + true + _other -> + false + end)} + end) + |> ok(fn functions -> + Enum.map(functions, fn {:function, name, arity, _, code} -> + {"#{name}$#{arity}", Function.to_ast(code)} + end) + |> Map.new() + end) + + case Keyword.get(opts, :only) do + nil -> + function_map + only -> + Map.take(function_map, only) + end + end +end diff --git a/lib/beam_js/utils.ex b/lib/beam_js/utils.ex new file mode 100644 index 0000000..adfe81b --- /dev/null +++ b/lib/beam_js/utils.ex @@ -0,0 +1,4 @@ +defmodule BeamJs.Utils do + def ok({:ok, value}, fun), do: fun.(value) + def ok(other, _fun), do: other +end diff --git a/mix.exs b/mix.exs index d6ec3df..200e7ae 100644 --- a/mix.exs +++ b/mix.exs @@ -6,11 +6,15 @@ defmodule BeamJs.MixProject do app: :beam_js, version: "0.1.0", elixir: "~> 1.19", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps() ] end + def elixirc_paths(:test), do: ["lib", "test/support"] + def elixirc_paths(_), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ diff --git a/mix.lock b/mix.lock index ddb8226..1cba670 100644 --- a/mix.lock +++ b/mix.lock @@ -1,3 +1,6 @@ %{ "beam_file": {:hex, :beam_file, "0.6.4", "73660f76330e6b29be2b8ae3779b30cb67a7803cde88a21e3e74371130eed4d4", [:mix], [], "hexpm", "3f295dba08a68360903e86be4f183d7fb70f762ee37ee176438dde23ea494431"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, } diff --git a/test/beam_js_test.exs b/test/beam_js_test.exs index e326d3b..ca4ecf6 100644 --- a/test/beam_js_test.exs +++ b/test/beam_js_test.exs @@ -1,8 +1,19 @@ defmodule BeamJsTest do - use ExUnit.Case + use BeamJs.JsCase doctest BeamJs - test "greets the world" do - assert BeamJs.hello() == :world + test "can run JS code", %{js: js} do + assert run(js, "1") == {:ok, 1} + end + + test "can encode and import", %{js: js} do + js_src = + Example + |> BeamJs.Module.to_ast(only: ["fact$1"]) + |> BeamJs.Encoder.encode_module() + + File.write!("tmp/example.mjs", js_src) + + assert {:ok, [[_label, [["func_info", "Elixir.Example", "fact", 1]]]| _rest]} = run(js, ~s|const Example = await import('./tmp/example.mjs');JSON.stringify(Example.fact$1)|) end end diff --git a/test/support/example.ex b/test/support/example.ex new file mode 100644 index 0000000..2bbc2b4 --- /dev/null +++ b/test/support/example.ex @@ -0,0 +1,5 @@ +defmodule Example do + def fact(0), do: 1 + def fact(1), do: 1 + def fact(n), do: fact(n - 1) + fact(n - 2) +end diff --git a/test/support/js_case.ex b/test/support/js_case.ex new file mode 100644 index 0000000..a39843c --- /dev/null +++ b/test/support/js_case.ex @@ -0,0 +1,14 @@ +defmodule BeamJs.JsCase do + use ExUnit.CaseTemplate + + using do + quote do + import BeamJs.JsRuntime, only: [run: 2] + end + end + + setup _tags do + {:ok, pid} = BeamJs.JsRuntime.start_link() + %{js: pid} + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..0a76a58 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ ExUnit.start() +