# Makefile for Firehose app MISE_BIN ?= mise MISE_EXEC = $(MISE_BIN) exec -- .PHONY: check precommit deps assets compile test test.coverage coverage crap format format.check credo verify # Run all static analysis checks (no database required). `format.check` only # reports: a gate that rewrites the files it judges can never fail. Fix # formatting with `make format`. check: credo format.check # Precommit target for CI/pre-commit hooks precommit: check compile # Install JS dependencies (esbuild bundle, playwright). `mix phx.server` and # `mix assets.deploy` need them: without `assets/node_modules` the bundle fails # to resolve highlight.js and LiveView never loads. # # Guarded on npm's own install marker, so a clone pays for it once and later # runs never touch the network: `make test` keeps working offline. assets: @test -f assets/node_modules/.package-lock.json || \ (echo "==> assets: npm install (first run)"; cd assets && npm install) # Sync dependencies (Hex packages + JS packages) deps: assets $(MISE_EXEC) mix deps.get # Compile the project compile: $(MISE_EXEC) mix compile --warnings-as-errors # Run tests (requires PostgreSQL running on localhost:5432) # Note: If you don't have PostgreSQL, you can skip tests with `make check` test: deps compile $(MISE_EXEC) mix test # Run the suite once with coverage exported, so the coverage floor check (and, # later, CRAP) reads the export instead of running the suite a second time. # Stale exports are removed first: the floor must never report an older run. test.coverage: deps compile rm -f cover/*.coverdata $(MISE_EXEC) mix test --cover --export-coverage default # Enforce the coverage floor (mix.exs -> test_coverage) from that export. No # test run. `mix test.coverage` exits 0 when it finds no export at all, so the # guard below is load-bearing: a floor check that did not run must not pass. coverage: @test -f cover/default.coverdata || { echo "coverage: no exported coverage - run 'make test.coverage' first" >&2; exit 1; } $(MISE_EXEC) mix test.coverage # Enforce CRAP scores (complexity x coverage) from the same export. No test run. # Runs in MIX_ENV=test because that is the environment the export came from: # running in dev would compile a second build and score against a different set # of modules. The guard is load-bearing for the same reason as `coverage`: # ex_crap falls back to 0% coverage when the export is missing, which would # report scores that are pessimistic instead of absent - a false failure is # less bad than a false pass, but neither is acceptable in the Lens pass. # # Threshold is ex_crap's default (30), kept deliberately: decision 2026-10-28 # (human, yak "ratchets, deferred decisions, handoff practice") is not to # ratchet --max-score down yet - below 30 is a policy change, not a cleanup. # Baseline at that threshold, same date: 0 offenders above 30, worst function # FirehoseWeb.CoreComponents.table/1 at 20.00 ("Summary: files=23 functions=140 # scored=140 worst_score=20.00"). A failing run prints "High scores: N" plus the # offending functions; on a passing run read N=0 off the worst score rather than # inventing a count. crap: @test -f cover/default.coverdata || { echo "crap: no exported coverage - run 'make test.coverage' first" >&2; exit 1; } MIX_ENV=test $(MISE_EXEC) mix crap # Format code. MUTATES the tree; deliberately not part of `check` (nor of the # Lens pass), which must never write: a gate that fixes what it judges can never # fail, and would dirty the tree a push is about to send. `make check` uses # `format.check` below instead. format: $(MISE_EXEC) mix format # Verify formatting without writing. Fails with the offending files, so the fix # is explicit: run `make format` (or `make -C app format`). format.check: $(MISE_EXEC) mix format --check-formatted # ============================================================================= # Playwright e2e browser tests # ============================================================================= # # Requires the Phoenix dev server running on the default port (8056). # Set PLAYWRIGHT_BASE_URL to point at a different server/port. # # Available browsers (local): # PLAYWRIGHT_BROWSER=firefox (default — works on macOS 15) # PLAYWRIGHT_BROWSER=chromium (crashes on macOS 15, use Docker Chrome) # PLAYWRIGHT_BROWSER=webkit # # Docker Chrome (alternative): # 1. docker compose -f docker-compose.chrome.yml up -d # 2. PLAYWRIGHT_BROWSERS_PATH=assets/node_modules/playwright-core/.local-browsers \ # npx playwright test --config=playwright.docker.config.ts # # ============================================================================= # Run Playwright e2e tests (default: local Firefox) # Usage: make playwright # PLAYWRIGHT_BROWSER=chromium make playwright playwright: cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts # Run a specific Playwright test file # Usage: make playwright-test test/e2e/smoke.spec.ts playwright-test: cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts $(filter-out $@,$(MAKECMDGOALS)) # Run Playwright tests with UI mode (headed browser) playwright-ui: cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts --ui # Show last Playwright HTML report playwright-report: cd assets && npx playwright show-report # Start Docker Chrome container for remote Playwright playwright-docker-start: docker compose -f docker-compose.chrome.yml up -d # Stop Docker Chrome container playwright-docker-stop: docker compose -f docker-compose.chrome.yml down # Run Playwright against Docker Chrome (container must be running) playwright-docker: cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts # Run full end-to-end workflow: start Docker Chrome, run tests, stop container playwright-full: playwright-docker-start @echo "Waiting for Chrome to be ready..." @sleep 3 cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts; \ EXIT_CODE=$$?; \ cd ..; \ docker compose -f docker-compose.chrome.yml down; \ exit $$EXIT_CODE # ============================================================================= # Static analysis # ============================================================================= # Run Credo static analysis credo: $(MISE_EXEC) mix credo --strict # ============================================================================= # Lens pass (heavy checks; root `make verify` runs this before every push) # ============================================================================= # # Sensor slots, cheapest first. Credo is cheap enough that it lives in # `check`, not here. Add a sensor as its yak lands, both as a target and to # VERIFY_SENSORS — that variable is the single source of truth for what this # target reports as wired. # - sobelow (yak: sobelow in the Lens pass) # - coverage (yak: coverage floors + framework-glue exclusions policy) # WIRED below (test.coverage + coverage). # - crap (yak: CRAP scores with ex_crap (both apps)) # WIRED below (crap); reuses the coverage export. # - mutation (yak: mutation testing with muex — a sensor, not a gate) # # Runtime note: the coverage sensor exports from a single suite run # (`test.coverage` -> `mix test --cover --export-coverage default`) that CRAP # reuses, instead of running the suite twice. # # The suite runs FIRST, because it is what produces the coverage export the # sensors read; sensors still run cheapest-first among themselves. VERIFY_SENSORS := coverage crap verify: @echo "==> verify(app): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]" @$(MAKE) test.coverage @for sensor in $(VERIFY_SENSORS); do echo "==> verify(app): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done