defmodule Mix.Tasks.LiveInteractionContracts.Test do @shortdoc "Run the LiveView interaction conformance harness and assert the fuse" @moduledoc """ Boots a bundled LiveView fixture app against a chosen LiveView version, drives it with Playwright in real browsers, and asserts the frozen classification (green machines green, red fixtures red, deterministic). Exits non-zero on any regression — this is the fuse. mix live_interaction_contracts.test mix live_interaction_contracts.test --browser chromium mix live_interaction_contracts.test --suite combobox mix live_interaction_contracts.test --lv "github:phoenixframework/phoenix_live_view#main" Options: * `--suite` harness | cursor | combobox | dialog-adapter | popover | menu | tooltip | island (default: harness — the one with the ledger + fuse) * `--browser` chromium | firefox | webkit (default: all three) * `--lv` LiveView version spec (default: host project's version, else 1.2.7) * `--port` fixture HTTP port (default per suite) Requires `elixir`, `node`, and Playwright browsers (`npx playwright install`) on PATH. Intended for `only: [:dev, :test]` use. """ use Mix.Task @suites %{ "harness" => %{dir: "harness", app: "app.exs", driver: "suite.mjs", result: "results.json", port: 4123, assert: true, ledger: true}, "cursor" => %{dir: "cursor-spike", app: "app.exs", driver: "spike.mjs", port: 4126, assert: false, ledger: false}, "combobox" => %{dir: "combobox-spike", app: "app.exs", driver: "spike.mjs", port: 4127, assert: false, ledger: false}, # Adapter suite: the driver self-asserts 7/7 (its own fuse), so exit code carries it. "dialog-adapter" => %{dir: "dialog-adapter-spike", app: "app.exs", driver: "spike.mjs", port: 4128, assert: false, ledger: false}, # Reference component: dogfoods the shipped <.popover> + hook; self-asserts 6/6. "popover" => %{dir: "popover-reference", app: "app.exs", driver: "driver.mjs", port: 4129, assert: false, ledger: false}, # Reference component: dogfoods the shipped <.menu> + hook (popover + cursor); self-asserts 7/7. "menu" => %{dir: "menu-reference", app: "app.exs", driver: "driver.mjs", port: 4130, assert: false, ledger: false}, # Reference component: dogfoods the shipped <.tooltip> + hook (popover + hover/focus trigger); self-asserts 7/7. "tooltip" => %{dir: "tooltip-reference", app: "app.exs", driver: "driver.mjs", port: 4131, assert: false, ledger: false}, "select" => %{dir: "select-reference", app: "app.exs", driver: "driver.mjs", port: 4134, assert: false, ledger: false}, # Reference component: dogfoods the shipped <.combobox> + hook + Channel guard; self-asserts 9/9. "combobox-reference" => %{dir: "combobox-reference", app: "app.exs", driver: "driver.mjs", port: 4135, assert: false, ledger: false}, # Contract proof: client-built children need phx-update=ignore or any patch wipes them. "island" => %{dir: "island-survival-spike", app: "app.exs", driver: "driver.mjs", port: 4132, assert: false, ledger: false} } @impl true def run(argv) do {opts, _, _} = OptionParser.parse(argv, strict: [suite: :string, browser: :string, lv: :string, port: :integer] ) suite_name = opts[:suite] || "harness" suite = @suites[suite_name] || Mix.raise("unknown --suite #{suite_name} (#{@suites |> Map.keys() |> Enum.sort() |> Enum.join("|")})") lv = LiveInteractionContracts.resolve_lv_spec(opts[:lv]) port = opts[:port] || suite.port browser = opts[:browser] priv = LiveInteractionContracts.priv_dir() fixture = Path.join(priv, suite.dir) # Generated ledger goes to the invocation cwd (the consuming project root), # never into _build where priv resolves. root = File.cwd!() Mix.shell().info("== live_interaction_contracts: suite=#{suite_name} lv=#{lv} browser=#{browser || "all"} port=#{port}") Mix.shell().info("== fixture: #{fixture}") script = orchestration(fixture, suite, port, lv, browser, root) case System.cmd("bash", ["-c", script], into: IO.stream(:stdio, :line), stderr_to_stdout: true) do {_, 0} -> Mix.shell().info("== conformance fuse OK") {_, code} -> Mix.raise("conformance fuse tripped (exit #{code})") end end # Reuse the proven boot/drive/assert/teardown shell pattern. Kills by port so no # orphaned BEAM node survives the fixture's Mix.install launcher. defp orchestration(fixture, suite, port, lv, browser, root) do only = if browser, do: "ONLY_BROWSER=#{sh(browser)}", else: "" assert_step = if suite.assert, do: "node ci-assert.mjs; ASSERT=$?", else: "ASSERT=$SUITE" result_cleanup = if suite[:result], do: "rm -f #{sh(suite.result)}", else: ":" # Ledger failure trips the fuse too: a green fuse with a stale/missing ledger # would be a false pass (the README promises test == run + regenerate + assert). ledger_step = if suite.ledger, do: "LEDGER_DIR=#{sh(root)} RESULTS_FILE=#{sh(suite.result)} node ledger.mjs; LEDGER=$?", else: "LEDGER=0" """ set -o pipefail cd #{sh(fixture)} # Wait until the port is GENUINELY free before booting. A dying BEAM can still # hold the port (and briefly answer curl) for more than a second after SIGTERM; # booting into that race gave an :eaddrinuse crash + a stale-server goto timeout. port_free() { for i in $(seq 1 60); do lsof -nP -t -iTCP:#{port} -sTCP:LISTEN >/dev/null 2>&1 || return 0; sleep 0.5; done; return 1; } kill $(lsof -nP -t -iTCP:#{port} -sTCP:LISTEN) 2>/dev/null || true port_free || { echo "PORT #{port} STILL IN USE AFTER 30s"; exit 3; } LV_SPEC=#{sh(lv)} PORT=#{port} VERSIONS_OUT=versions.json elixir #{suite.app} > server.log 2>&1 & APP_PID=$! # Only the server we just booted can answer now (port was confirmed free). for i in $(seq 1 120); do curl -sf -o /dev/null http://127.0.0.1:#{port}/ && break; kill -0 $APP_PID 2>/dev/null || { echo "FIXTURE PROCESS DIED"; tail -30 server.log; exit 2; }; sleep 2; done if ! curl -sf -o /dev/null http://127.0.0.1:#{port}/; then echo "FIXTURE FAILED TO BOOT"; tail -30 server.log; exit 2; fi #{result_cleanup} #{only} PORT=#{port} node #{suite.driver}; SUITE=$? #{ledger_step} #{assert_step} kill $(lsof -nP -t -iTCP:#{port} -sTCP:LISTEN) 2>/dev/null || true port_free || true # leave the port clean for the next suite invocation RESULT=$SUITE if [ "$RESULT" -eq 0 ]; then RESULT=${ASSERT:-0}; fi if [ "$RESULT" -eq 0 ] && [ "$LEDGER" -ne 0 ]; then echo "LEDGER GENERATION FAILED (exit $LEDGER)"; RESULT=$LEDGER; fi exit $RESULT """ end # Single-quote a value for safe interpolation into the bash script (paths with # spaces, lv specs with # / &, etc.). defp sh(value), do: "'" <> String.replace(to_string(value), "'", "'\\''") <> "'" end