Skip to content

Commit

Permalink
refactor: mneme.watch test runner (#94)
Browse files Browse the repository at this point in the history
* refactor: additional config opts for mneme.watch test runner

* chore: create test runner behaviour and impl

* test: add tests for mneme.watch test runner

* test: fix test runner tests when using the test runner

Don't rely on global application config, which affects the runner when dog-fooding.

* ci: try to fix inotify-tools error
  • Loading branch information
zachallaun authored Oct 18, 2024
1 parent 67733f1 commit 6989c02
Show file tree
Hide file tree
Showing 7 changed files with 337 additions and 56 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ jobs:
steps:
- uses: actions/checkout@v3

- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: inotify-tools
version: 1.0

- name: Set up Elixir
id: beam
uses: erlef/setup-beam@v1
Expand Down
26 changes: 17 additions & 9 deletions lib/mneme/watch/elixir_files.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Mneme.Watch.ElixirFiles do

use GenServer

defstruct [:subscriber, paths: [], timeout_ms: 200]
defstruct [:subscriber, :dir, paths: [], timeout_ms: 200]

@doc """
Start a file system watcher linked to the current process and
Expand All @@ -15,23 +15,30 @@ defmodule Mneme.Watch.ElixirFiles do
## Options
* `:timeout_ms` (default `200`)- amount of time to wait before
* `:timeout_ms` (default `200`) - amount of time to wait before
emitting `:files_changed` events. Events received during this time
are deduplicated.
* `:dir` (default `[File.cwd!()]`) - directories to watch
"""
@spec watch!(timeout_ms: non_neg_integer()) :: :ok
@spec watch!([opt]) :: pid()
when opt: {:timeout_ms, non_neg_integer()} | {:dir, [Path.t()]}
def watch!(opts \\ []) do
:ok = Application.ensure_started(:file_system)

opts =
{:ok, pid} =
opts
|> Keyword.validate!([:timeout_ms])
|> Keyword.validate!(timeout_ms: 200, dir: File.cwd!())
|> Keyword.put(:subscriber, self())
|> start_link()

{:ok, _} = start_link(opts)
pid
end

:ok
@doc false
def simulate_file_event(pid, path) do
send(pid, {:file_event, self(), {path, [:modified]}})
end

@doc false
Expand All @@ -41,7 +48,8 @@ defmodule Mneme.Watch.ElixirFiles do

@impl GenServer
def init(opts) do
{:ok, file_system} = FileSystem.start_link(dirs: [File.cwd!()])
dir = Keyword.fetch!(opts, :dir)
{:ok, file_system} = FileSystem.start_link(dirs: [dir])
:ok = FileSystem.subscribe(file_system)

{:ok, struct!(__MODULE__, opts)}
Expand All @@ -65,7 +73,7 @@ defmodule Mneme.Watch.ElixirFiles do

defp maybe_add_path(%__MODULE__{} = state, full_path) do
project = Mix.Project.config()
relative_path = Path.relative_to_cwd(full_path)
relative_path = Path.relative_to(full_path, state.dir)

if watching?(project, relative_path) do
update_in(state.paths, &[full_path | &1])
Expand Down
185 changes: 143 additions & 42 deletions lib/mneme/watch/test_runner.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
defmodule Mneme.Watch.TestRunner do

Check warning on line 1 in lib/mneme/watch/test_runner.ex

View workflow job for this annotation

GitHub Actions / test (1.14, 26)

@behaviour Mneme.Watch.TestRunner does not exist (in module Mneme.Watch.TestRunner)

Check warning on line 1 in lib/mneme/watch/test_runner.ex

View workflow job for this annotation

GitHub Actions / test (1.14, 24)

@behaviour Mneme.Watch.TestRunner does not exist (in module Mneme.Watch.TestRunner)

Check warning on line 1 in lib/mneme/watch/test_runner.ex

View workflow job for this annotation

GitHub Actions / test (1.14, 25)

@behaviour Mneme.Watch.TestRunner does not exist (in module Mneme.Watch.TestRunner)
@moduledoc false

@behaviour __MODULE__

use GenServer

defstruct [:cli_args, :testing, paths: [], about_to_save: [], exit_on_success?: false]
alias Mneme.Watch

@callback compiler_options(keyword()) :: term()
@callback after_suite((term() -> term())) :: :ok
@callback skip_all() :: :ok
@callback system_halt(non_neg_integer()) :: no_return()
@callback run_tests(cli_args :: [String.t()], system_restart_marker :: Path.t()) :: term()
@callback io_write(term()) :: :ok

defstruct [
:impl,
:cli_args,
:testing,
:system_restart_marker,
:file_watcher,
paths: [],
about_to_save: [],
exit_on_success?: false
]

@doc false
@doc """
Starts a test runner that reruns tests when files change.
## Options
* `:cli_args` (required) - list of command-line arguments
* `:watch` (default `[]`) - keyword options passed to
`Mneme.Watch.ElixirFiles.watch!/1`
* `:manifest_path` (default `Mix.Project.manifest_path/0`) - dir
used to track state between runs.
* `:name` (default `Mneme.Watch.TestRunner`) - registered name of
the started process
* `:impl` (default `Mneme.Watch.TestRunner`) - implementation module
for the `Mneme.Watch.TestRunner` behaviour (used for testing)
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
defaults = [
cli_args: [],
watch: [],
manifest_path: Mix.Project.manifest_path(),
name: __MODULE__,
impl: __MODULE__
]

{name, opts} =
opts
|> Keyword.validate!(defaults)
|> Keyword.pop!(:name)

GenServer.start_link(__MODULE__, opts, name: name)
end

@doc """
Expand All @@ -21,11 +73,20 @@ defmodule Mneme.Watch.TestRunner do
end
end

@doc false
def simulate_file_event(test_runner, path) do
GenServer.cast(test_runner, {:simulate_file_event, path})
end

@impl GenServer
def init(opts) do
Code.compiler_options(ignore_module_conflict: true)
:ok = Mneme.Watch.ElixirFiles.watch!()
[cli_args: args] = Keyword.validate!(opts, [:cli_args])
impl = Keyword.fetch!(opts, :impl)
args = Keyword.fetch!(opts, :cli_args)
watch_opts = Keyword.fetch!(opts, :watch)
manifest_path = Keyword.fetch!(opts, :manifest_path)

impl.compiler_options(ignore_module_conflict: true)
file_watcher = Watch.ElixirFiles.watch!(watch_opts)

{cli_args, exit_on_success?} =
if "--exit-on-success" in args do
Expand All @@ -34,12 +95,18 @@ defmodule Mneme.Watch.TestRunner do
{args, false}
end

state = %__MODULE__{cli_args: cli_args, exit_on_success?: exit_on_success?}
state = %__MODULE__{
impl: impl,
cli_args: cli_args,
exit_on_success?: exit_on_success?,
system_restart_marker: Path.join(manifest_path, "mneme.watch.restart"),
file_watcher: file_watcher
}

runner = self()
ExUnit.after_suite(fn result -> send(runner, {:after_suite, result}) end)
impl.after_suite(fn result -> send(runner, {:after_suite, result}) end)

if check_system_restarted!() do
if check_system_restarted!(state.system_restart_marker) do
{:ok, state}
else
{:ok, state, {:continue, :force_schedule_tests}}
Expand All @@ -48,7 +115,7 @@ defmodule Mneme.Watch.TestRunner do

@impl GenServer
def handle_continue(:force_schedule_tests, state) do
{:noreply, %{state | testing: run_tests_async(state.cli_args)}}
{:noreply, %{state | testing: run_tests_async(state)}}
end

def handle_continue(:maybe_schedule_tests, %{testing: %Task{}} = state) do
Expand All @@ -60,18 +127,22 @@ defmodule Mneme.Watch.TestRunner do
end

def handle_continue(:maybe_schedule_tests, state) do
IO.write("\r\n")

state.paths
|> Enum.uniq()
|> Enum.sort()
|> Enum.each(fn path ->
Owl.IO.puts([Owl.Data.tag("reloading: ", :cyan), path])
end)

IO.write("\n")

state = %{state | testing: run_tests_async(state.cli_args), paths: []}
reloads =
state.paths
|> Enum.uniq()
|> Enum.sort()
|> Enum.map(fn path ->
prefix = "reloading: " |> Owl.Data.tag(:cyan) |> Owl.Data.to_chardata()
[prefix, path, "\n"]
end)

state.impl.io_write([
"\r\n",
reloads,
"\n"
])

state = %{state | testing: run_tests_async(state), paths: []}

{:noreply, state}
end
Expand All @@ -86,7 +157,7 @@ defmodule Mneme.Watch.TestRunner do
|> Map.update!(:paths, &(relevant_paths ++ &1))

if state.testing do
Mneme.Server.skip_all()
state.impl.skip_all()
end

{:noreply, state, {:continue, :maybe_schedule_tests}}
Expand All @@ -102,7 +173,7 @@ defmodule Mneme.Watch.TestRunner do

def handle_info({:after_suite, result}, state) do
if state.exit_on_success? and result.failures == 0 do
System.halt(0)
state.impl.system_halt(0)
end

{:noreply, state}
Expand All @@ -113,37 +184,67 @@ defmodule Mneme.Watch.TestRunner do
{:noreply, put_in(state.about_to_save, paths)}
end

defp run_tests_async(cli_args) do
Task.async(fn -> run_tests(cli_args) end)
def handle_cast({:simulate_file_event, path}, state) do
Watch.ElixirFiles.simulate_file_event(state.file_watcher, path)
{:noreply, state}
end

defp run_tests_async(%__MODULE__{} = state) do
impl = state.impl
cli_args = state.cli_args
system_restart_marker = state.system_restart_marker
Task.async(fn -> impl.run_tests(cli_args, system_restart_marker) end)
end

defp run_tests(cli_args) do
defp check_system_restarted!(system_restart_marker) do
restarted? = File.exists?(system_restart_marker)
_ = File.rm(system_restart_marker)
restarted?
end

defp write_system_restart_marker!(system_restart_marker) do
File.touch!(system_restart_marker)
end

@impl __MODULE__
def compiler_options(opts) do
Code.compiler_options(opts)
end

@impl __MODULE__
def after_suite(callback) do
ExUnit.after_suite(callback)
end

@impl __MODULE__
def skip_all do
Mneme.Server.skip_all()
end

@impl __MODULE__
def system_halt(status) do
System.halt(status)
end

@impl __MODULE__
def io_write(data) do
IO.write(data)
end

@impl __MODULE__
def run_tests(cli_args, system_restart_marker) do
Code.unrequire_files(Code.required_files())
recompile()
Mix.Task.reenable(:test)
Mix.Task.run(:test, cli_args)
catch
:exit, _ ->
write_system_restart_marker!()
write_system_restart_marker!(system_restart_marker)
System.restart()
end

@dialyzer {:nowarn_function, recompile: 0}
defp recompile do
IEx.Helpers.recompile()
end

defp check_system_restarted! do
restarted? = File.exists?(system_restart_marker())
_ = File.rm(system_restart_marker())
restarted?
end

defp write_system_restart_marker! do
File.touch!(system_restart_marker())
end

defp system_restart_marker do
Path.join([Mix.Project.manifest_path(), "mneme.watch.restart"])
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ defmodule Mneme.MixProject do
{:ecto, "~> 3.9", only: :test},
{:stream_data, "~> 1.0", only: [:dev, :test]},
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:styler, "~> 1.0", only: [:dev, :test], runtime: false},
{:styler, "~> 1.0", only: [:dev, :test]},
{:time_zone_info, "~> 0.7", only: [:dev, :test]},
{:excoveralls, "~> 0.18", only: :test},
{:patch, "~> 0.13.1", only: :test},
{:mox, "~> 1.2", only: :test},

# Docs
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
"makeup_json": {:hex, :makeup_json, "0.1.1", "44204f3f023ff3daca682cc0b1dc372098514460064599979cb4cde5926cff70", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "3879d78117e37a9b1e567b9cc76c1b5b51b9efc5f4f4301ea5e53fb70c59c718"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"owl": {:hex, :owl, "0.12.0", "0c4b48f90797a7f5f09ebd67ba7ebdc20761c3ec9c7928dfcafcb6d3c2d25c99", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "241d85ae62824dd72f9b2e4a5ba4e69ebb9960089a3c68ce6c1ddf2073db3c15"},
"patch": {:hex, :patch, "0.13.1", "2da5b508e4d6558924a0959d95dc3aa8176b5ccf2539e4567481448d61853ccc", [:mix], [], "hexpm", "75f805827d9db0c335155fbb857e6eeb5c85034c9dc668d146bc0bfe48fac822"},
Expand Down
7 changes: 3 additions & 4 deletions test/mneme/watch/elixir_files_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ defmodule Mneme.Watch.ElixirFilesTest do
patch(FileSystem, :start_link, {:ok, :file_system})
patch(FileSystem, :subscribe, :ok)

pid = start_supervised!({ElixirFiles, subscriber: self(), timeout_ms: 10})
pid =
start_supervised!({ElixirFiles, subscriber: self(), timeout_ms: 10, dir: File.cwd!()})

{:ok, pid: pid}
end

defp file_events(pid, paths) when is_list(paths) do
for path <- paths do
send(pid, {:file_event, self(), {path, [:modified]}})
end
for path <- paths, do: ElixirFiles.simulate_file_event(pid, path)
end

test "emits files in test/", %{pid: pid} do
Expand Down
Loading

0 comments on commit 6989c02

Please sign in to comment.