Current section
Files
Jump to
Current section
Files
lib/mix/tasks/live_interaction_contracts.check.ex
defmodule Mix.Tasks.LiveInteractionContracts.Check do
@shortdoc "Check a consumer LiveView interaction contract in a real browser"
@moduledoc """
Runs one declarative interaction contract, or a deterministic directory suite of
independent contracts, against an already-started LiveView app. The runner performs
real interactions, requires a subject-local patch acknowledgment, compares DOM
object identity and native browser state, and writes JSON evidence.
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contract test/interaction_contracts/account_menu.json
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contracts test/interaction_contracts \
--out-dir tmp/interaction-contract-results
Options:
* `--url` — required base URL; loopback only unless `--allow-remote`
* `--contract` — one v1 JSON contract path
* `--contracts` — directory of independent first-level `*.json` contracts
* `--browser` — chromium (default), firefox, or webkit
* `--out` — single-contract JSON evidence path (default: live-interaction-contracts-result.json)
* `--out-dir` — required suite evidence directory
* `--allow-remote` — permit an explicitly safe non-loopback test target
* `--validate-only` — validate contract/target without launching a browser
Exit status is non-zero for fail, inconclusive, and infrastructure/contract error.
"""
use Mix.Task
@version Mix.Project.config()[:version] || "dev"
@impl true
def run(argv) do
{opts, positional, invalid} =
OptionParser.parse(argv,
strict: [
url: :string,
contract: :string,
contracts: :string,
browser: :string,
out: :string,
out_dir: :string,
allow_remote: :boolean,
validate_only: :boolean
]
)
if opts[:contracts], do: run_suite(argv), else: run_single(opts, positional, invalid)
end
defp run_suite(argv) do
case run_node(runner!(), argv) do
{_, 0} ->
:ok
{_, code} ->
Mix.raise("interaction contract suite did not pass (exit #{code})", exit_status: code)
end
end
defp run_single(opts, positional, invalid) do
out = Path.expand(opts[:out] || "live-interaction-contracts-result.json")
contract = opts[:contract] && Path.expand(opts[:contract])
out_dir = opts[:out_dir] && Path.expand(opts[:out_dir])
if contract == out, do: Mix.raise("--out must not overwrite --contract")
File.rm(out)
if positional != [] or invalid != [] do
Mix.raise("unexpected arguments: #{inspect(positional ++ invalid)}")
end
url = opts[:url] || Mix.raise("--url required")
contract = contract || Mix.raise("--contract required")
if out_dir, do: Mix.raise("--out-dir is only valid with --contracts")
browser = opts[:browser] || "chromium"
unless browser in ~w(chromium firefox webkit),
do: Mix.raise("unsupported --browser #{browser}")
unless File.regular?(contract),
do: Mix.raise("--contract is not a regular file: #{contract}")
args = ["--url", url, "--browser", browser, "--contract", contract, "--out", out]
args = if opts[:allow_remote], do: args ++ ["--allow-remote"], else: args
args = if opts[:validate_only], do: args ++ ["--validate-only"], else: args
case run_node(runner!(), args) do
{_, 0} -> :ok
{_, code} -> Mix.raise("interaction contract did not pass (exit #{code})")
end
end
defp runner! do
runner = Path.join([LiveInteractionContracts.priv_dir(), "contract-runner", "check.mjs"])
unless File.regular?(runner), do: Mix.raise("contract runner missing from package: #{runner}")
runner
end
defp run_node(runner, args) do
env = [{"LIC_RUNNER_VERSION", @version}]
System.cmd("node", [runner | args],
into: IO.stream(:stdio, :line),
stderr_to_stdout: true,
env: env
)
end
end