Current section
Files
Jump to
Current section
Files
lib/examples/e_hot_reload.ex
defmodule Examples.EHotReload do
@moduledoc """
I test the hot-reload system end-to-end against a real dependency.
HotReloadTest is a path dep under fixtures/ with a struct (Config)
and a struct caller (Builder). Examples modify its source, verify
the change took effect, then revert. Running examples leaves no
traces.
All mutation examples go through `with_reload/3` which handles
the write-compile-revert cycle. Each example only specifies what
to change and what to observe.
"""
use ExExample
import ExUnit.Assertions
def rerun?(_), do: true
# I return {path, original_content} for HotReloadTest's root module.
@spec resolve_source() :: {String.t(), String.t()}
example resolve_source do
{path, original} = source_for(HotReloadTest)
assert String.contains?(original, "defmodule HotReloadTest")
{path, original}
end
# I add a function to a dep and return the module's updated
# export list.
@spec add_function() :: [{atom(), non_neg_integer()}]
example add_function do
with_reload(HotReloadTest, &inject_before_end(&1, "def __test__, do: :it_works"), fn ->
# apply/3 because __test__/0 is injected at runtime — static
# analysis can't see it, so a direct call would warn.
assert apply(HotReloadTest, :__test__, []) == :it_works
HotReloadTest.__info__(:functions)
end)
end
# I modify hello's return value and return the formatted source
# from disk.
@spec modify_function() :: String.t()
example modify_function do
{path, _} = source_for(HotReloadTest)
with_reload(HotReloadTest, &String.replace(&1, "do: :world", "do: :modified"), fn ->
# apply/3 because the static type of `HotReloadTest.hello/0`
# is `:world` (its compile-time definition); the test mutates
# it to return `:modified`, and a direct call would trigger
# the `:world == :modified` disjoint-types warning.
assert apply(HotReloadTest, :hello, []) == :modified
File.read!(path)
end)
end
# I add a new field to Config, then add an accessor to Builder
# that uses it. Proves cross-module hot-reload works when
# changes depend on each other.
@spec new_field_propagates() :: String.t()
example new_field_propagates do
with_reload(
HotReloadTest.Config,
&String.replace(&1, "value: 0", ~s(value: 0, extra: "propagated")),
fn ->
with_reload(
HotReloadTest.Builder,
&inject_before_end(&1, ~s(def extra, do: %Config{}.extra)),
fn ->
# apply/3: `extra/0` is injected at runtime via the
# outer with_reload's transform.
result = apply(HotReloadTest.Builder, :extra, [])
assert result == "propagated"
result
end
)
end
)
end
# I add a function and return the beam file's export list —
# proving beams are persisted to ebin.
@spec beam_persisted() :: [{atom(), non_neg_integer()}]
example beam_persisted do
with_reload(HotReloadTest, &inject_before_end(&1, "def __beam__, do: :ok"), fn ->
exports = beam_exports(HotReloadTest)
assert {:__beam__, 0} in exports
exports
end)
end
# I hot-reload a standalone file with no application registration
# and return its module info.
@spec standalone_module() :: keyword()
example standalone_module do
mod = :"Elixir.HotReloadStandaloneTest"
path = Path.join(System.tmp_dir!(), "hot_reload_standalone_test.ex")
source =
"defmodule HotReloadStandaloneTest do\n def value, do: :standalone\nend\n"
try do
GtBridge.HotReload.reload(path, source)
assert apply(mod, :value, []) == :standalone
assert Application.get_application(mod) == nil
apply(mod, :module_info, [])
after
File.rm(path)
:code.purge(mod)
:code.delete(mod)
end
end
# I verify revert restores source, beam, and module exports.
# Returns exports after revert.
@spec revert_clean() :: [{atom(), non_neg_integer()}]
example revert_clean do
{path, original} = source_for(HotReloadTest)
modified = inject_before_end(original, "def __revert__, do: :tmp")
GtBridge.HotReload.reload(path, modified)
assert function_exported?(HotReloadTest, :__revert__, 0)
GtBridge.HotReload.reload(path, original)
refute function_exported?(HotReloadTest, :__revert__, 0)
assert File.read!(path) == original
exports = beam_exports(HotReloadTest)
refute {:__revert__, 0} in exports
exports
end
############################################################
# Helpers #
############################################################
# I handle the write-compile-revert cycle. `transform` receives
# the original source and returns the modified version. `observe`
# runs after hot-reload and its return value becomes the example
# result. Revert is automatic.
defp with_reload(mod, transform, observe) do
{path, original} = source_for(mod)
try do
GtBridge.HotReload.reload(path, transform.(original))
observe.()
after
GtBridge.HotReload.reload(path, original)
end
end
defp source_for(mod) do
path = GtBridge.Resolve.source_file(mod)
{:ok, content} = File.read(path)
{path, content}
end
defp inject_before_end(source, definition) do
String.replace(source, "\nend\n", "\n #{definition}\nend\n")
end
defp beam_exports(mod) do
app = Application.get_application(mod)
beam = Path.join(Application.app_dir(app, "ebin"), "#{mod}.beam")
{:ok, {_, [{:exports, exports}]}} =
:beam_lib.chunks(String.to_charlist(beam), [:exports])
exports
end
end