Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts lib mix tasks live_interaction_contracts.test.ex
Raw

lib/mix/tasks/live_interaction_contracts.test.ex

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.5)
* `--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", 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", ["-lc", 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"
# 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)} 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
#{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=${ASSERT:-$SUITE}
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