defmodule Mix.Tasks.LiveInteractionContracts.Check do @shortdoc "Check a consumer LiveView interaction contract in a real browser" @moduledoc """ Runs one declarative interaction contract against an already-started LiveView app. The runner performs real clicks, 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 Options: * `--url` — required base URL; loopback only unless `--allow-remote` * `--contract` — required v1 JSON contract path * `--browser` — chromium (default), firefox, or webkit * `--out` — JSON evidence path (default: live-interaction-contracts-result.json) * `--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, browser: :string, out: :string, allow_remote: :boolean, validate_only: :boolean ] ) out = Path.expand(opts[:out] || "live-interaction-contracts-result.json") contract = opts[:contract] && Path.expand(opts[:contract]) 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") 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}") runner = Path.join([LiveInteractionContracts.priv_dir(), "contract-runner", "check.mjs"]) unless File.regular?(runner), do: Mix.raise("contract runner missing from package: #{runner}") args = ["--url", url, "--contract", contract, "--browser", browser, "--out", out] args = if opts[:allow_remote], do: args ++ ["--allow-remote"], else: args args = if opts[:validate_only], do: args ++ ["--validate-only"], else: args env = [{"LIC_RUNNER_VERSION", @version}] case System.cmd("node", [runner | args], into: IO.stream(:stdio, :line), stderr_to_stdout: true, env: env) do {_, 0} -> :ok {_, code} -> Mix.raise("interaction contract did not pass (exit #{code})") end end end