Add infrastructure for JS testing

This commit is contained in:
Robert Prehn 2026-03-30 15:43:34 -05:00
parent dfb24274b0
commit d3ea64cc15
No known key found for this signature in database
15 changed files with 368 additions and 131 deletions

3
config/test.exs Normal file
View file

@ -0,0 +1,3 @@
import Config
config :nodejs, debug_mode: true

14
js/index.mjs Normal file
View file

@ -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,
};

View file

@ -2,132 +2,10 @@ defmodule BeamJs do
@moduledoc """ @moduledoc """
Documentation for `BeamJs`. 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 end
def transform_function_code(ops) do defimpl JSON.Encoder, for: BeamJs.Boxed do
Enum.reduce(ops, [], fn def encode(value, encoder) do
{:label, number}, [{line_no, head_ops} | tail] -> ["BeamJs.boxed(", encoder.(value.type, encoder), ", ", encoder.(value.value, encoder),")"]
[{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
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

3
lib/beam_js/boxed.ex Normal file
View file

@ -0,0 +1,3 @@
defmodule BeamJs.Boxed do
defstruct [:type, :value]
end

191
lib/beam_js/encoder.ex Normal file
View file

@ -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

21
lib/beam_js/function.ex Normal file
View file

@ -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

53
lib/beam_js/js_runtime.ex Normal file
View file

@ -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

32
lib/beam_js/module.ex Normal file
View file

@ -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

4
lib/beam_js/utils.ex Normal file
View file

@ -0,0 +1,4 @@
defmodule BeamJs.Utils do
def ok({:ok, value}, fun), do: fun.(value)
def ok(other, _fun), do: other
end

View file

@ -6,11 +6,15 @@ defmodule BeamJs.MixProject do
app: :beam_js, app: :beam_js,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.19", elixir: "~> 1.19",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
deps: deps() deps: deps()
] ]
end end
def elixirc_paths(:test), do: ["lib", "test/support"]
def elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications. # Run "mix help compile.app" to learn about applications.
def application do def application do
[ [

View file

@ -1,3 +1,6 @@
%{ %{
"beam_file": {:hex, :beam_file, "0.6.4", "73660f76330e6b29be2b8ae3779b30cb67a7803cde88a21e3e74371130eed4d4", [:mix], [], "hexpm", "3f295dba08a68360903e86be4f183d7fb70f762ee37ee176438dde23ea494431"}, "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"},
} }

View file

@ -1,8 +1,19 @@
defmodule BeamJsTest do defmodule BeamJsTest do
use ExUnit.Case use BeamJs.JsCase
doctest BeamJs doctest BeamJs
test "greets the world" do test "can run JS code", %{js: js} do
assert BeamJs.hello() == :world 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
end end

5
test/support/example.ex Normal file
View file

@ -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

14
test/support/js_case.ex Normal file
View file

@ -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

View file

@ -1 +1,2 @@
ExUnit.start() ExUnit.start()