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 @doc """ I add a brand-new module and verify its beam lands in the app's ebin: a new module is in no app spec, so per-module app resolution skipped it and it existed only in memory until the next full mix compile. Reverting removes the module, which must delete the beam again. Returns the new module's beam exports. """ @spec new_module_beam_persisted() :: [{atom(), non_neg_integer()}] example new_module_beam_persisted do {path, original} = source_for(HotReloadTest) fresh = "\ndefmodule HotReloadTest.Fresh do\n def here, do: :yes\nend\n" beam = Path.join( Application.app_dir(:hot_reload_test, "ebin"), "Elixir.HotReloadTest.Fresh.beam" ) try do GtBridge.HotReload.reload(path, original <> fresh) assert File.exists?(beam) {:ok, {_, [{:exports, exports}]}} = :beam_lib.chunks(String.to_charlist(beam), [:exports]) assert {:here, 0} in exports GtBridge.HotReload.reload(path, original) refute File.exists?(beam) exports after GtBridge.HotReload.reload(path, original) end end @doc """ I remove a module from a file and verify the reload purges it: the compile result is the file's full module set, so a module absent from it was deleted from the source. Message-driven - I wait on the broker's own events (no sleeps) and sync the projection with a get_state barrier before reading it. Returns the app's surviving module names. """ @spec removed_module_purged() :: [String.t()] example removed_module_purged do {path, original} = source_for(HotReloadTest) doomed = "\ndefmodule HotReloadTest.Doomed do\n def gone, do: :soon\nend\n" EventBroker.subscribe_me([%GtBridge.Events.AnyModuleEvent{}]) try do GtBridge.HotReload.reload(path, original <> doomed) assert_receive %EventBroker.Event{ body: %GtBridge.Events.ModuleEvent{ kind: :recompiled, mod: HotReloadTest.Doomed } }, 15_000 :sys.get_state(GtBridge.Analysis.LoadedModules) assert GtBridge.Analysis.LoadedModules.loaded?("HotReloadTest.Doomed") GtBridge.HotReload.reload(path, original) assert_receive %EventBroker.Event{ body: %GtBridge.Events.ModuleEvent{ kind: :source_removed, mod: HotReloadTest.Doomed } }, 15_000 :sys.get_state(GtBridge.Analysis.LoadedModules) refute GtBridge.Analysis.LoadedModules.loaded?("HotReloadTest.Doomed") refute :code.is_loaded(HotReloadTest.Doomed) GtBridge.Analysis.LoadedModules.all_names() |> Enum.filter(&String.starts_with?(&1, "HotReloadTest")) after EventBroker.unsubscribe_me([%GtBridge.Events.AnyModuleEvent{}]) GtBridge.HotReload.reload(path, original) 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