Compare commits
36
Commits
cee64b9f74
...
f5faa8a3af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5faa8a3af | ||
|
|
edba66239e | ||
|
|
9c0f33e6ca | ||
|
|
b2bd6bc3aa | ||
|
|
ef8407b272 | ||
|
|
13e3186f58 | ||
|
|
3ef38bc8fc | ||
|
|
09da15c1df | ||
|
|
e292b4ebfb | ||
|
|
b72446b80e | ||
|
|
44a343ae72 | ||
|
|
cc03cefe8d | ||
|
|
c04d8c856c | ||
|
|
4e7798c906 | ||
|
|
a6a7b3a8c7 | ||
|
|
a72d73f21c | ||
|
|
2ccbe28774 | ||
|
|
2ba0b7bc48 | ||
|
|
3eae3afcbb | ||
|
|
a0692056bc | ||
|
|
be80c89548 | ||
|
|
e3fc734862 | ||
|
|
4b4d0c491c | ||
|
|
9730987846 | ||
|
|
f54ce2fab7 | ||
|
|
cb13ccecab | ||
|
|
abb712f8a2 | ||
|
|
754c8fa1b9 | ||
|
|
a9a02aa9ba | ||
|
|
96278e0d02 | ||
|
|
5db2814c2e | ||
|
|
068911b4e8 | ||
|
|
0b815dc66a | ||
|
|
ea588bd670 | ||
|
|
888aa8b00b | ||
|
|
cbbdaae53d |
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# pre-push: the Lens pass. Runs `make verify` (cheap gate + heavy sensors + both
|
||||
# test suites) and blocks the push if anything fails.
|
||||
#
|
||||
# Install with `make hooks` (symlinks this file into this clone's git hooks
|
||||
# directory). Deliberate bypass: `git push --no-verify`.
|
||||
#
|
||||
# Why a symlink and not `git config core.hooksPath .githooks`: bd keeps its own
|
||||
# managed hooks in .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge
|
||||
# imports it). Pointing core.hooksPath elsewhere would silently retire them, and
|
||||
# bd would still report its hooks as installed. Installing one symlink per hook
|
||||
# in .githooks/ keeps every hook manager's files where they expect them.
|
||||
#
|
||||
# stdin carries "<local ref> <local sha> <remote ref> <remote sha>" per ref being
|
||||
# pushed; the Lens pass is repo-wide, so it is deliberately not read.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if ! cd "$(git rev-parse --show-toplevel)"; then
|
||||
echo "pre-push: cannot find the repository root; refusing to let this push through" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v make >/dev/null 2>&1; then
|
||||
echo "pre-push: make not found on PATH — the Lens pass cannot run, so the push is blocked" >&2
|
||||
echo "pre-push: install make, or bypass deliberately with: git push --no-verify" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log=$(git rev-parse --git-dir)/lens-pass.log
|
||||
echo "pre-push: running the Lens pass (make verify) — full log: $log"
|
||||
|
||||
set -o pipefail
|
||||
if make verify 2>&1 | tee "$log"; then
|
||||
echo "pre-push: Lens pass passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
last_step=$(grep -E '^==> ' "$log" | tail -n 1)
|
||||
{
|
||||
echo
|
||||
echo "=================================================================="
|
||||
echo "pre-push: LENS PASS FAILED — push blocked"
|
||||
echo " last step started: ${last_step:-unknown (see log)}"
|
||||
echo " full log: $log"
|
||||
echo " triage the failure (real bug / environment-dependent test /"
|
||||
echo " design nit — see AGENTS.md), then re-run: make verify"
|
||||
echo " deliberate bypass: git push --no-verify"
|
||||
echo "=================================================================="
|
||||
} >&2
|
||||
exit 1
|
||||
+58
-23
@@ -46,7 +46,7 @@ Build an understanding of what to demo from these sources (in priority order):
|
||||
Explicit scenario (--scenario): If provided, use as primary guide.
|
||||
Plan file: Read the plan's Goal, Acceptance Criteria, and completed Steps.
|
||||
Recent commits: Run git log --oneline -15 and git diff main...HEAD --stat to identify changed files and commit messages.
|
||||
Route map: Cross-reference changed files against known LiveView routes in the router (lib/hub_web/router.ex).
|
||||
Route map: Find the router yourself - do not assume a path. Search the project for its router (`grep -rn "use Phoenix.Router" lib/`; conventionally lib/<app>_web/router.ex), then read the scopes, pipelines and live_sessions to learn which routes the feature touches, which are public, and which require a logged-in user.
|
||||
|
||||
From these sources, produce a demo outline:
|
||||
|
||||
@@ -56,42 +56,77 @@ From these sources, produce a demo outline:
|
||||
Interactions: any clicks, form fills, or navigation sequences to perform
|
||||
|
||||
If no plan exists and commits are ambiguous, ask the user for a brief description of what to demo.
|
||||
3. Check dev server
|
||||
3. Resolve the dev port, then check the server
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ 2>/dev/null
|
||||
Never assume a port. Read it from the project config, from the repo root:
|
||||
|
||||
DEMO_PORT=$(cd app && mix run --no-start -e \
|
||||
'IO.puts(Application.get_env(:firehose, FirehoseWeb.Endpoint)[:http][:port])')
|
||||
DEMO_BASE_URL="http://localhost:${DEMO_PORT}"
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" "$DEMO_BASE_URL/"
|
||||
|
||||
Adapt that command to the project: find the app/endpoint pair in `config/*.exs`
|
||||
(`grep -rn "Endpoint," config/`) and use the same names. If the config reads the
|
||||
port from the environment, the value above already reflects it. Do not resolve
|
||||
the port with `<Endpoint>.config(:http)`: with `--no-start` the endpoint's ETS
|
||||
table does not exist and it raises.
|
||||
|
||||
If the project pins a toolchain or wraps builds, use its wrapper for every
|
||||
command below (`mise exec -- mix ...`, asdf shims, or its `make` targets - check
|
||||
AGENTS.md/README first). Bare `mix` is not a safe assumption.
|
||||
|
||||
If the server is not reachable:
|
||||
|
||||
The Phoenix dev server is not running. Start it now?
|
||||
|
||||
mix phx.server &
|
||||
mkdir -p .tmp && (cd app && PORT=$DEMO_PORT mix phx.server \
|
||||
> ../.tmp/phx-demo.log 2>&1 &)
|
||||
|
||||
After starting, wait up to 10 seconds and verify connectivity. If it still fails, proceed with backend-only evidence (skip all browser screenshots) and note the limitation in the demo document.
|
||||
4. Handle authentication
|
||||
Then poll `$DEMO_BASE_URL` for up to 10 seconds. If it still fails, read
|
||||
`.tmp/phx-demo.log`: an error about an unresolvable JS module means the
|
||||
project's JS packages are not installed - run its assets install step (in this
|
||||
repository `make -C app assets`, which root `make deps` also runs). Only when
|
||||
the server really cannot come up, proceed with backend-only evidence (skip all
|
||||
browser screenshots) and note the limitation in the demo document.
|
||||
|
||||
The app requires authentication for LiveView routes. Before capturing UI screenshots:
|
||||
Use `$DEMO_BASE_URL` for every URL below - there is no default port to fall
|
||||
back on.
|
||||
4. Handle authentication (only if the route needs it)
|
||||
|
||||
Ensure a demo user exists:
|
||||
Decide this from the project, not from this file. If every page you need is
|
||||
public, skip this step. A route needs a session when the router found in step 2
|
||||
puts it inside a `pipe_through` that requires a logged-in user, or inside a
|
||||
`live_session` guarded that way.
|
||||
|
||||
mix run -e "
|
||||
alias Hub.Accounts
|
||||
case Accounts.get_user_by_email(\"demo@example.com\") do
|
||||
nil -> Accounts.register_user(%{email: \"demo@example.com\", password: \"demodemo1234\"})
|
||||
user -> {:ok, user}
|
||||
end
|
||||
"
|
||||
When you do need a session, read each part from the project instead of assuming
|
||||
it:
|
||||
|
||||
Log in via rodney:
|
||||
- Credentials: check README, AGENTS.md, docs and the project's seeds
|
||||
(`priv/repo/seeds.exs`, a `mix demo`-style task). Use documented demo
|
||||
credentials if they exist - never invent them.
|
||||
- Demo user: if nothing is documented, create one through the project's own
|
||||
path, in this order: run its seeds; use its registration route; call the
|
||||
accounts/context function you find by searching for it (`grep -rn
|
||||
"get_user_by_email\|register_user\|create_user" lib/`). Copy the exact module,
|
||||
function and field names from the source; do not guess an alias.
|
||||
- Login route, form fields and submit button: read them off the router and out of
|
||||
the rendered form (`rodney html form` or `rodney ax-tree`) rather than
|
||||
assuming a path or an input name.
|
||||
|
||||
Then drive the form with the selectors you just read:
|
||||
|
||||
rodney start
|
||||
rodney open http://localhost:4000/users/log-in
|
||||
rodney wait "input[name='user[email]']"
|
||||
rodney input "input[name='user[email]']" "demo@example.com"
|
||||
rodney input "input[name='user[password]']" "demodemo1234"
|
||||
rodney click "button[type='submit']"
|
||||
rodney open "$DEMO_BASE_URL<login-route>"
|
||||
rodney wait "<email-input-selector>"
|
||||
rodney input "<email-input-selector>" "<email>"
|
||||
rodney input "<password-input-selector>" "<password>"
|
||||
rodney click "<submit-selector>"
|
||||
rodney waitidle
|
||||
|
||||
If login fails, warn and proceed with backend-only evidence.
|
||||
Confirm the session took - `rodney url` should have left the login route, and
|
||||
the page should show the user (name, avatar, menu). If login fails, warn, note it
|
||||
in the demo document, and proceed with backend-only evidence.
|
||||
5. Initialize the demo document
|
||||
|
||||
Slugify the feature name (lowercase, hyphens, no special chars). Then:
|
||||
@@ -136,7 +171,7 @@ For each UI page identified in step 2, navigate with rodney, screenshot, and emb
|
||||
|
||||
Static page capture:
|
||||
|
||||
rodney open http://localhost:4000/<route>
|
||||
rodney open "$DEMO_BASE_URL/<route>"
|
||||
rodney waitidle
|
||||
rodney screenshot demos/screenshots/<feature>-<page-name>.png
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ firehose/
|
||||
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
|
||||
├── Dockerfile # Deploy build; ARG pins must match mise.toml
|
||||
├── scripts/check-pins.sh # Verifies the pin sync; runs in `make check`
|
||||
└── Makefile # make (test, check)
|
||||
├── .githooks/pre-push # Lens pass on push; install once with `make hooks`
|
||||
└── Makefile # make (check, verify, test, hooks)
|
||||
```
|
||||
|
||||
Two blogs are configured:
|
||||
@@ -69,9 +70,137 @@ This is a Phoenix Liveview monorepo, the blogging library is in blogex, the appl
|
||||
|
||||
Main make targets:
|
||||
|
||||
- make test
|
||||
- make check - runs credo static analysis and suggests refactorings
|
||||
- `make check` - cheap static gate for both apps (no DB): app `credo --strict` +
|
||||
format **check** (`mix format --check-formatted`), blogex compile (warnings
|
||||
fatal) + format **check**, then `scripts/check-pins.sh`. It never rewrites
|
||||
files.
|
||||
- `make precommit` - alias of `check`; fast, safe to run on every commit
|
||||
- `make test` - both suites (app needs PostgreSQL on localhost:5432)
|
||||
- `make verify` - the Lens pass: `check`, then per-app heavy sensors and both suites
|
||||
- `make hooks` - install `.githooks/*` into this clone (once per clone)
|
||||
|
||||
# Verification: fast loop vs Lens pass
|
||||
|
||||
Two loops, deliberately split so commits stay cheap and pushes stay honest.
|
||||
|
||||
**Fast loop (every commit).** `make check` is static analysis only: no database,
|
||||
no coverage, about a second. `make precommit` is the same target. Per app,
|
||||
`mix precommit` (run inside `app/` or `blogex/`) is the fuller loop: compile with
|
||||
warnings fatal, format, test. Run these as often as you like while working.
|
||||
|
||||
`make check` never writes files. A formatting failure means run `make format`
|
||||
(both apps). The mutating loops are the explicit ones: `make format` and the
|
||||
per-app `mix precommit`, which still formats as you work.
|
||||
|
||||
**Lens pass (every push, and before handing off).** `make verify` runs `check`
|
||||
first (cheapest), then per app: one suite run that exports its coverage, then
|
||||
that app's sensors (sobelow, coverage, CRAP; mutation as that yak lands), which
|
||||
run cheapest-first and read the export rather than re-running the suite. Measured
|
||||
2026-11-01: ~11s wall (app 178 tests + blogex 156 tests + coverage floor).
|
||||
|
||||
`.githooks/pre-push` runs `make verify` and blocks the push when anything fails.
|
||||
Install it once per clone:
|
||||
|
||||
make hooks
|
||||
|
||||
It symlinks `.githooks/*` into the clone's hooks directory; `core.hooksPath` is
|
||||
deliberately left alone so bd's own hooks (pre-commit JSONL flush, post-merge
|
||||
import) keep firing. On failure the hook prints one summary - last step started,
|
||||
log path, bypass - and exits non-zero; the full output is in `.git/lens-pass.log`.
|
||||
Deliberate override (e.g. pushing a branch you know is red): `git push --no-verify`.
|
||||
|
||||
## Lens practice
|
||||
|
||||
The Lens pass is a *separate pass with its own context*. It is not a way to find
|
||||
work mid-feature: finish and commit what you are building, then run it.
|
||||
|
||||
- Do not chase hook or `credo` output inline while building a feature. If
|
||||
something interesting surfaces, note it and file a yak; take it up in a Lens
|
||||
session.
|
||||
- A failing Lens pass blocks the push, and therefore the hand-off. Triage it;
|
||||
never re-run until green and never ignore it.
|
||||
- Adding a sensor: implement it as its own make target, add it to that app's
|
||||
`VERIFY_SENSORS` in `app/Makefile` / `blogex/Makefile` (cheapest first), and
|
||||
update the slot comments there. A gate that reports a check it did not run is
|
||||
worse than no gate - the `VERIFY_SENSORS` list is what gets echoed. Sensors run
|
||||
*after* the app's `test.coverage` step, because the coverage export they read is
|
||||
produced by that suite run; if a check can pass without its input, make it fail
|
||||
loudly (see the `coverage` target's guard).
|
||||
|
||||
### Triage classes
|
||||
|
||||
| Class | Looks like | What to do |
|
||||
|-------|-----------|------------|
|
||||
| *Real bug* | behavior is wrong; a test fails for a real reason; a sensor reports a real risk | fix inline, with a test that pins the behavior |
|
||||
| *Environment-dependent test* | passes alone, fails in the pass; depends on ambient state (rows left behind, wall clock, ordering, network, locale) | the test is the bug: make it deterministic by construction - own fixtures, explicit clock, no ambient state as fixtures. Never retry-until-green |
|
||||
| *Style or design nit* | credo or format suggestion, naming, structure, "this could be smaller" | do not refactor mid-build: note it and file a yak; the Lens session decides |
|
||||
|
||||
Reconciling this with the zero-defects policy: "never continue when there are
|
||||
errors" governs *what you do with a failure* - report, triage, decide - not
|
||||
*when* you fix it. Never silently absorb a finding, and never let a pre-existing
|
||||
failure ride along: if it predates your change, stop and discuss it with the user
|
||||
rather than working around it.
|
||||
|
||||
Browser tests (`make playwright*`) are *external* verification and are **not**
|
||||
part of the Lens pass. Decided 2026-10-28 (human, yak "ratchets, deferred
|
||||
decisions, handoff practice"): it stays that way - Playwright is not wired into
|
||||
`make verify` or into either `VERIFY_SENSORS`. The targets need a running
|
||||
Phoenix server or the Docker Chrome container, and a gate that only runs with
|
||||
docker or sudo is not a gate the Lens pass can rely on. Run them as their own
|
||||
loop, by hand, when a change touches the browser surface.
|
||||
|
||||
### Landing the plane
|
||||
|
||||
The Lens pass is what you land with, so it is the last step of every session,
|
||||
not a thing that only happens on push:
|
||||
|
||||
- After any session that changed code, run `make precommit`. It is `make check`
|
||||
under its session-ending name and costs about a second; there is no session
|
||||
where it is not worth running.
|
||||
- Before handing off - to a human, a reviewer, or the next agent - run
|
||||
`make verify`, or push, which fires `.githooks/pre-push` and runs the same
|
||||
pass on the same tree. A hand-off note that says "tests pass" without a Lens
|
||||
pass behind it is a guess, not a hand-off.
|
||||
- A failing Lens pass is triaged into the classes above (see *Triage classes*)
|
||||
and **reported** in the hand-off note, with the class it fell in. A nit gets a
|
||||
yak; it does not get fixed inline mid-build. Never re-run until green.
|
||||
- `git push --no-verify` stays the deliberate override for a branch you know is
|
||||
red. It is never how a session lands.
|
||||
|
||||
## Ratchets
|
||||
|
||||
The coverage threshold in `app/mix.exs` and `blogex/mix.exs` (the `threshold:`
|
||||
inside each `test_coverage/0`) is a **floor**, not a target:
|
||||
|
||||
- After each behavior-test yak lands, re-measure both apps - `make test.coverage`
|
||||
then `make coverage` inside each app - and raise the floor to the new total.
|
||||
The convention is integer truncation of the measurement: a measured 82.61%
|
||||
means a floor of 82. Never above the measurement: a floor that cannot pass is
|
||||
not a ratchet, it is a red gate.
|
||||
- Never lower a floor. A legitimate drop is a conversation, not a config edit.
|
||||
Lowering the number, or adding an `ignore_modules` entry to prop it up, hides
|
||||
the behavior gap the floor exists to surface.
|
||||
- The record of the last measurement and its date is the comment above
|
||||
`test_coverage/0` in that app's `mix.exs`. Update it there, in the same commit
|
||||
as the number. Do not copy the totals into this file: one record, in the
|
||||
place the gate reads, is the whole point.
|
||||
|
||||
Two further health numbers are baselines-to-be; neither is `make verify`'s
|
||||
business yet, and neither is to be invented:
|
||||
|
||||
- **CRAP offender count.** `make crap` in one app is the sensor: a passing run
|
||||
prints `Summary: ... worst_score=...`, a failing run prints `High scores: N`
|
||||
and the offending functions. Both apps sit under the default `--max-score
|
||||
(30)`, and the decision (2026-10-28) not to lower it, plus the baseline
|
||||
offender count, live in the comment above the `crap` target in
|
||||
`app/Makefile` / `blogex/Makefile`.
|
||||
- **Mutation survivor count.** Mutation is a slot comment only in
|
||||
`app/Makefile` and `blogex/Makefile` ("yak: mutation testing with muex - a
|
||||
sensor, not a gate"), and `muex` is a dependency of neither app. Nothing has
|
||||
been mutated, so there is no survivor baseline; do not conjure one from a run
|
||||
that did not happen. When the mutation yak lands, its survivors become the
|
||||
baseline the same way the coverage floor did: measured, dated, recorded where
|
||||
the sensor lives.
|
||||
|
||||
# Development
|
||||
Always fix failing tests, credo issues and format isseus.
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
# Makefile for Firehose monorepo
|
||||
|
||||
.PHONY: check precommit deps compile test test-elisp format
|
||||
MISE_BIN ?= mise
|
||||
|
||||
# Common check target that runs all static analysis
|
||||
.PHONY: check precommit verify hooks deps compile test test-elisp format
|
||||
|
||||
# Cheap static gate over both apps, cheapest first. No database, no coverage:
|
||||
# this is the loop you run freely while working and before every commit.
|
||||
check:
|
||||
@echo "Running static analysis..."
|
||||
@make -C app MISE_BIN=mise check
|
||||
@echo "==> check: app (credo --strict, format)"
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) check
|
||||
@echo "==> check: blogex (compile, format)"
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) check
|
||||
@echo "==> check: toolchain pins"
|
||||
@bash scripts/check-pins.sh
|
||||
@echo "==> check: Lens sensor wiring"
|
||||
@bash scripts/check-lens-sensors.sh
|
||||
|
||||
# Precommit target for CI/pre-commit hooks
|
||||
precommit: check
|
||||
|
||||
# The Lens pass: everything `check` does, plus, per app, one suite run (which
|
||||
# exports its coverage) and that app's sensors reading the export. Sensors run
|
||||
# cheapest-first among themselves. `.githooks/pre-push` runs this before every
|
||||
# push (see `make hooks`); run it yourself before handing off. Bypass
|
||||
# deliberately with `git push --no-verify`.
|
||||
verify: check
|
||||
@echo "==> verify: app"
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) verify
|
||||
@echo "==> verify: blogex"
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) verify
|
||||
|
||||
# Install the versioned hooks in .githooks/ into this clone's hooks directory as
|
||||
# symlinks (edits take effect immediately, no copy to drift). Idempotent; re-run
|
||||
# after moving the clone, since the links are absolute.
|
||||
#
|
||||
# core.hooksPath is deliberately NOT changed: bd manages its own hooks inside
|
||||
# .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge imports it) and
|
||||
# moving the hooks path would silently retire them.
|
||||
hooks:
|
||||
@hooks_dir=$$(git rev-parse --git-path hooks); \
|
||||
root=$$(git rev-parse --show-toplevel); \
|
||||
mkdir -p "$$hooks_dir"; \
|
||||
for hook in .githooks/*; do \
|
||||
name=$$(basename "$$hook"); \
|
||||
ln -sfn "$$root/$$hook" "$$hooks_dir/$$name"; \
|
||||
echo "installed $$hooks_dir/$$name -> $$hook"; \
|
||||
done
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
@make -C app deps
|
||||
@@ -28,9 +64,11 @@ test:
|
||||
test-elisp:
|
||||
emacs --batch -Q -l org -l ox-md -l scripts/roam-export-blog.el -l scripts/roam-export-blog-test.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
# Format code
|
||||
# Format code in both apps. Mutating convenience target: `check` (and so
|
||||
# `verify`) never writes. If a gate fails on formatting, run this.
|
||||
format:
|
||||
@make -C app format
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) format
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) format
|
||||
|
||||
# Serve the app
|
||||
serve:
|
||||
|
||||
+24
-1
@@ -4,6 +4,13 @@
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
# The gate for this app is `mix credo --strict` on the command line (the
|
||||
# `credo` target in Makefile, a prerequisite of `check`). The command line, not
|
||||
# this file, decides: `--strict` overrides the inert `strict: false` key below.
|
||||
# Editing `strict` here to silence a finding would change nothing - fix the
|
||||
# finding, or write down why the check is wrong for this codebase (see
|
||||
# `disabled:`).
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
@@ -41,6 +48,11 @@
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
# This is the ONLY load path for lib_dev/ checks: the directory is kept out
|
||||
# of elixirc_paths in mix.exs on purpose, so the module is not also loaded
|
||||
# from a compiled .beam (that double load produced
|
||||
# "warning: redefining module Firehose.Checks.NoConnShadowing").
|
||||
#
|
||||
requires: ["lib_dev/firehose/checks/"],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
@@ -121,7 +133,6 @@
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
@@ -169,6 +180,18 @@
|
||||
{Firehose.Checks.NoConnShadowing, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
|
||||
# threshold 30, wired into the Lens pass in Makefile) is this app's
|
||||
# complexity sensor, and it scores complexity against real coverage: a
|
||||
# wide `case` that the tests exercise is not a problem - that was the
|
||||
# itch behind ExCrap. A second, coverage-blind complexity sensor would
|
||||
# just argue with the first, so complexity questions are settled by
|
||||
# the CRAP target. Re-enabling this check without retiring the CRAP
|
||||
# target is a policy change, not a cleanup.
|
||||
#
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Sobelow per-finding skips. Enabled by `--skip` in `app/Makefile`.
|
||||
#
|
||||
# Rule: every entry is preceded by a rationale comment saying what the check
|
||||
# cannot see and why the protection is real (or why the finding is a false
|
||||
# positive). An unexplained skip is a defect; `scripts/check-lens-sensors.sh`
|
||||
# enforces this. Fingerprints are sobelow's own; a code change that moves a
|
||||
# skip's line invalidates it and the sensor fails loudly rather than staying
|
||||
# quiet.
|
||||
#
|
||||
# Baseline 2026-10-28, sobelow 0.17.0: 3 findings (2 High, 1 Low), all skipped.
|
||||
|
||||
# Config.CSP - High - lib/firehose_web/router.ex:13 (pipeline :browser)
|
||||
# No CSP exists. put_secure_browser_headers does not set one and the root
|
||||
# layout has an inline theme script with no nonce, so choosing a policy is a
|
||||
# design decision rather than a mechanical fix.
|
||||
# Follow-up yak: app: add a Content-Security-Policy (sobelow Config.CSP)
|
||||
Config.CSP: Missing Content-Security-Policy,lib/firehose_web/router.ex:13,486EC13
|
||||
|
||||
# Config.HTTPS - High - config/prod.exs
|
||||
# No force_ssl: production TLS is terminated by the Dokku proxy. Whether the
|
||||
# endpoint should also force SSL/HSTS (and trust X-Forwarded-Proto) is a
|
||||
# deployment decision, not a mechanical fix.
|
||||
# Follow-up yak: app: enforce HTTPS in production (sobelow Config.HTTPS)
|
||||
Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077
|
||||
|
||||
# Traversal.FileModule - Low - lib/firehose_web/live/microprints_live.ex:331
|
||||
# False positive: the client path is only read after Enum.find matches it
|
||||
# against the server-scanned microprint list, and source_dir comes from
|
||||
# @source_dirs. Both Path.join inputs are server-computed, never client text.
|
||||
Traversal.FileModule: Directory Traversal in `File.read`,lib/firehose_web/live/microprints_live.ex:331,7ED2FE5
|
||||
+102
-6
@@ -3,16 +3,28 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: check precommit deps compile test format credo
|
||||
.PHONY: check precommit deps assets compile test test.coverage sobelow coverage crap format format.check credo verify
|
||||
|
||||
# Run all static analysis checks (no database required)
|
||||
check: credo format
|
||||
# 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
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
# 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
|
||||
@@ -24,10 +36,52 @@ compile:
|
||||
test: deps compile
|
||||
$(MISE_EXEC) mix test
|
||||
|
||||
# Format code
|
||||
# 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
|
||||
# =============================================================================
|
||||
@@ -95,3 +149,45 @@ playwright-full: playwright-docker-start
|
||||
# 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)
|
||||
# WIRED below (sobelow, ~1s). Fails loudly on findings and
|
||||
# when the task is missing — no fallback of any kind.
|
||||
# Per-finding skips exist only in .sobelow-skips, each with a
|
||||
# written rationale (pinned by scripts/check-lens-sensors.sh).
|
||||
# - 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 := sobelow coverage crap
|
||||
|
||||
# Sobelow security scan. `--exit` is load-bearing: without it sobelow prints
|
||||
# findings and exits 0 (verified 2026-10-28, sobelow 0.17.0: 3 findings -> 0,
|
||||
# `--exit` -> 1; it defaults to the `low` confidence threshold). `--skip`
|
||||
# applies only the individually rationalised entries in .sobelow-skips.
|
||||
# There is deliberately no guard or fallback: if the dep is absent, `mix
|
||||
# sobelow` is a hard Mix error ("The task could not be found") and the
|
||||
# verify loop's `|| exit 1` fails the pass.
|
||||
sobelow:
|
||||
$(MISE_EXEC) mix sobelow --skip --exit
|
||||
|
||||
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
|
||||
|
||||
+44
-14
@@ -27,7 +27,11 @@
|
||||
--color-base-200: oklch(94% 0.012 75);
|
||||
--color-base-300: oklch(90% 0.016 75);
|
||||
--color-base-content: oklch(25% 0.015 50);
|
||||
--color-primary: oklch(58% 0.16 45);
|
||||
/* 54% lightness, not 58%: primary is also *text* (pagination links, body
|
||||
links, the post-title hover). At 58% it measured 4.18:1 on base-100, below
|
||||
the 4.5:1 floor; 54% is 4.93:1 there and 4.52:1 on base-200. Same hue and
|
||||
chroma - this is a contrast fix, not a re-colour. */
|
||||
--color-primary: oklch(54% 0.16 45);
|
||||
--color-primary-content: oklch(98% 0.01 75);
|
||||
--color-secondary: oklch(55% 0.12 30);
|
||||
--color-secondary-content: oklch(98% 0.008 30);
|
||||
@@ -53,11 +57,18 @@
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Warm dark theme */
|
||||
/* Warm dark theme.
|
||||
`prefersdark: false` deliberately: daisyUI emits an *unscoped*
|
||||
`@media (prefers-color-scheme: dark) { :root { … } }` for a prefersdark
|
||||
theme, which lands after the light block and beats `[data-theme="light"]` on
|
||||
equal specificity - an explicit light choice rendered dark on a dark-OS
|
||||
browser. The inline script in components/layouts/root.html.heex resolves
|
||||
"system" to an explicit data-theme instead, so no rule here may follow the
|
||||
OS preference on its own. */
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(24% 0.015 50);
|
||||
--color-base-200: oklch(20% 0.012 50);
|
||||
@@ -144,12 +155,12 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
}
|
||||
|
||||
.blogex-post-preview header h2 a {
|
||||
color: oklch(var(--color-base-content));
|
||||
color: var(--color-base-content);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blogex-post-preview header h2 a:hover {
|
||||
color: oklch(var(--color-primary));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.blogex-post-description {
|
||||
@@ -163,7 +174,10 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.6;
|
||||
/* 0.6 measured 4.16:1 (base-content over base-100, light theme) - below the
|
||||
4.5:1 floor. 0.625 is what the floor needs; 0.65 keeps a margin without
|
||||
making the meta line as loud as the description (0.7 -> 5.66:1). */
|
||||
opacity: 0.65;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -172,7 +186,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
background: oklch(var(--color-base-200));
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.blogex-post-header h1 {
|
||||
@@ -193,9 +207,9 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
.blogex-post-body p { margin-top: 1rem; }
|
||||
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
|
||||
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
|
||||
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
|
||||
.blogex-post-body pre { background: var(--color-base-200); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
|
||||
.blogex-post-body code { font-size: 0.875em; }
|
||||
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
|
||||
.blogex-post-body a { color: var(--color-primary); text-decoration: underline; }
|
||||
|
||||
/* External link marker on links rewritten by Blogex.ExternalLinks (class
|
||||
`blogex-external`, added at compile time together with target="_blank").
|
||||
@@ -219,7 +233,23 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
.blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
|
||||
/* Screen-reader hint injected by Blogex.ExternalLinks at the end of every
|
||||
external link's text. Visually hidden (the icon above is the visual cue),
|
||||
still announced: "Wikipedia, link, opens in a new tab". */
|
||||
.blogex-post-body .blogex-external-hint {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.blogex-post-body blockquote { border-left: 3px solid var(--color-primary); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
|
||||
|
||||
.blogex-tag-list {
|
||||
display: flex;
|
||||
@@ -232,13 +262,13 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
background: oklch(var(--color-base-200));
|
||||
background: var(--color-base-200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blogex-tag-link:hover, .blogex-tag-active {
|
||||
background: oklch(var(--color-primary));
|
||||
color: oklch(var(--color-primary-content));
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.blogex-pagination {
|
||||
@@ -251,7 +281,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
}
|
||||
|
||||
.blogex-pagination a {
|
||||
color: oklch(var(--color-primary));
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
defmodule Firehose do
|
||||
use Boundary,
|
||||
deps: [Firehose.Repo],
|
||||
exports: :all
|
||||
|
||||
@moduledoc """
|
||||
Firehose keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
@@ -2,6 +2,16 @@ defmodule Firehose.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
# `redact: true` below makes Ecto derive an `Inspect` impl for us. We write
|
||||
# that impl out by hand instead (see the `defimpl` after the schema):
|
||||
# Elixir 1.20.4 emits the derived code entirely on the `schema` line, and
|
||||
# OTP 27's coverage instrumentation turns that single line into invalid
|
||||
# Erlang ("variable '_2' unsafe in 'case'"), which aborts `mix test --cover`
|
||||
# for the whole app. Output and redaction are identical, and the redaction is
|
||||
# pinned by test/firehose/accounts_test.exs. Drop this and go back to the
|
||||
# derive when the Elixir/OTP pins no longer reproduce the crash.
|
||||
@derive_inspect_for_redacted_fields false
|
||||
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
@@ -12,6 +22,27 @@ defmodule Firehose.Accounts.User do
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
defimpl Inspect do
|
||||
alias Firehose.Accounts.User
|
||||
|
||||
# Same set as the `redact: true` field options above; keep them in sync.
|
||||
@redacted_fields [:password, :hashed_password]
|
||||
|
||||
def inspect(%User{} = user, opts) do
|
||||
infos =
|
||||
for %{field: field} = info <- User.__info__(:struct),
|
||||
field not in @redacted_fields,
|
||||
do: info
|
||||
|
||||
Inspect.Any.inspect_as_struct(
|
||||
user,
|
||||
Macro.inspect_atom(:literal, User),
|
||||
infos,
|
||||
opts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registering or changing the email.
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ defmodule Firehose.Application do
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
# Booting the app is the one job that must reach into the web tier (it starts
|
||||
# FirehoseWeb.Telemetry and FirehoseWeb.Endpoint) while the web tier reaches
|
||||
# back into the contexts. Declaring the Application a top-level boundary of
|
||||
# its own is the layout boundary's README prescribes for `mix phx.new`
|
||||
# projects, and it keeps the graph acyclic:
|
||||
# Firehose.Application -> {Firehose, FirehoseWeb} -> Firehose -> Firehose.Repo.
|
||||
# Putting FirehoseWeb in Firehose's deps instead is a cycle, which boundary
|
||||
# rejects ("dependency cycle found: Firehose -> FirehoseWeb -> Firehose").
|
||||
use Boundary, top_level?: true, deps: [Firehose, FirehoseWeb]
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
defmodule Firehose.Repo do
|
||||
use Boundary, top_level?: true, deps: [], exports: []
|
||||
|
||||
use Ecto.Repo,
|
||||
otp_app: :firehose,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
defmodule FirehoseWeb do
|
||||
use Boundary,
|
||||
deps: [Firehose],
|
||||
exports: [Endpoint, Telemetry]
|
||||
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, components, channels, and so on.
|
||||
|
||||
@@ -303,6 +303,16 @@ defmodule FirehoseWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
# `table/1` and `list/1` below are part of the upstream Phoenix generator's
|
||||
# core_components.ex and are kept available for when we need them — they are
|
||||
# genuinely useful, not leftover scaffolding to be deleted.
|
||||
#
|
||||
# Nothing renders them yet: the collections that exist in the UI are hand-rolled
|
||||
# instead (editor_dashboard_live.ex builds its drafts/scheduled rows out of
|
||||
# bordered divs; Blogex.Components.post_index renders post previews and tag
|
||||
# lists as articles/links). If you build a new data or admin screen, prefer
|
||||
# these two over more bespoke markup — and if you retrofit an existing screen,
|
||||
# drop the note here.
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
@@ -366,6 +376,7 @@ defmodule FirehoseWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
# Upstream scaffold, unused in-app today — see the note above `table/1`.
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ defmodule FirehoseWeb.Layouts do
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||
<%!-- The knob follows the visitor's choice, not the resolved palette: in
|
||||
system mode data-theme may be light or dark, but the system icon stays
|
||||
selected. --%>
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme-choice=light]_&]:left-1/3 [[data-theme-choice=dark]_&]:left-2/3 transition-[left]" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
|
||||
@@ -14,20 +14,37 @@
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
// Theme selection is always explicit: the document always carries a
|
||||
// data-theme attribute, resolved here before first paint. An explicit
|
||||
// choice therefore always wins in CSS, whatever the OS preference is;
|
||||
// no stylesheet rule follows the OS on its own.
|
||||
//
|
||||
// data-theme-choice records what the visitor actually chose (unset,
|
||||
// system, light, dark) and is what the toggle's knob follows: in
|
||||
// "system" mode the palette may resolve to light or dark, but the
|
||||
// system icon must stay selected.
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const stored = () => localStorage.getItem("phx:theme");
|
||||
const resolve = (theme) =>
|
||||
theme === "system" ? (prefersDark.matches ? "dark" : "light") : theme;
|
||||
const apply = (theme) => {
|
||||
document.documentElement.setAttribute("data-theme", resolve(theme));
|
||||
document.documentElement.setAttribute("data-theme-choice", theme);
|
||||
};
|
||||
const setTheme = (theme) => {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
apply(theme);
|
||||
};
|
||||
// Light is the default: no stored preference means light, not "follow
|
||||
// the OS". "System" is an explicit choice and is stored as such.
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
apply(stored() || "light");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
// Follow the OS only while the visitor's recorded choice is "system".
|
||||
prefersDark.addEventListener("change", () => {
|
||||
if (stored() === "system") apply("system");
|
||||
});
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "light"));
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -14,9 +14,8 @@ defmodule FirehoseWeb.Router do
|
||||
plug :fetch_current_scope_for_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
# No :api pipeline: the JSON blog API is served by
|
||||
# forward "/api/blog/...", Blogex.Router below, which sets its own content types.
|
||||
|
||||
scope "/", FirehoseWeb do
|
||||
pipe_through :browser
|
||||
|
||||
@@ -171,9 +171,15 @@ defmodule FirehoseWeb.UserAuth do
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
put_session(conn, :user_token, token)
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, user_session_topic(token))
|
||||
end
|
||||
|
||||
# Must match what Phoenix.LiveView.Socket.id/1 reads out of the socket's
|
||||
# connect_info session, so a "disconnect" broadcast reaches open sockets.
|
||||
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require sudo mode.
|
||||
"""
|
||||
|
||||
+171
-5
@@ -10,7 +10,8 @@ defmodule Firehose.MixProject do
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
test_coverage: test_coverage(),
|
||||
compilers: extra_compilers(Mix.env()) ++ [:phoenix_live_view] ++ Mix.compilers(),
|
||||
listeners: [Phoenix.CodeReloader]
|
||||
]
|
||||
end
|
||||
@@ -32,10 +33,25 @@ defmodule Firehose.MixProject do
|
||||
end
|
||||
|
||||
# Specifies which paths to compile per environment.
|
||||
defp elixirc_paths(:test), do: ["lib", "lib_dev", "test/support"]
|
||||
defp elixirc_paths(:dev), do: ["lib", "lib_dev"]
|
||||
#
|
||||
# `lib_dev/` is deliberately NOT compiled in any environment: it holds
|
||||
# credo-only checks (Firehose.Checks.NoConnShadowing), and credo loads them
|
||||
# itself from source via `requires:` in .credo.exs. Compiling that path as
|
||||
# well gave every run two load paths for the same module, which surfaces as
|
||||
# "warning: redefining module Firehose.Checks.NoConnShadowing" on a warm
|
||||
# `_build/dev`. Keep the single source load: if you add code under lib_dev/
|
||||
# that is NOT a credo check, give it its own compiled path instead.
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# The boundary compiler must be listed *before* `:elixir`: it enforces by
|
||||
# registering an `after_compiler(:elixir, ...)` callback, so a later position
|
||||
# would look wired and report nothing. It stays out of `:prod` on boundary's
|
||||
# own advice (a violation must not be able to fail a release build); `:dev`
|
||||
# and `:test` are where code is written.
|
||||
defp extra_compilers(env) when env in [:dev, :test], do: [:boundary]
|
||||
defp extra_compilers(_), do: []
|
||||
|
||||
# Specifies your project dependencies.
|
||||
#
|
||||
# Type `mix help deps` for examples and options.
|
||||
@@ -73,7 +89,15 @@ defmodule Firehose.MixProject do
|
||||
{:microprints,
|
||||
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
|
||||
branch: "main"},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
|
||||
# Compile-time boundary enforcement (Firehose / FirehoseWeb / Firehose.Repo).
|
||||
# Declared directly even though `ex_crap` already pulls it in transitively:
|
||||
# dropping a test-only metric tool must not silently take the boundary
|
||||
# compiler with it.
|
||||
{:boundary, "~> 0.11", runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
|
||||
{:muex, "~> 0.12", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.16", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -83,6 +107,14 @@ defmodule Firehose.MixProject do
|
||||
# $ mix setup
|
||||
#
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
#
|
||||
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
|
||||
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
|
||||
# survivor is reported rather than fatal. The scope lives in the plain-text
|
||||
# files beside this one; the aliases read them at run time, so the text file
|
||||
# stays the single source of truth. Both are empty today because the app's
|
||||
# candidate modules are only tested through the database-backed DataCase;
|
||||
# an empty scope raises instead of silently running over nothing.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
@@ -96,7 +128,141 @@ defmodule Firehose.MixProject do
|
||||
"esbuild firehose --minify",
|
||||
"phx.digest"
|
||||
],
|
||||
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
|
||||
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"],
|
||||
mutate: &mutate/1,
|
||||
"mutate.fast": &mutate_fast/1
|
||||
]
|
||||
end
|
||||
|
||||
# No macro/`use` mutators: the web layer (where the DSLs live) is out of
|
||||
# scope, and an empty scope never reaches muex at all. Spellings are muex
|
||||
# 0.12's CLI names.
|
||||
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
|
||||
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
|
||||
|
||||
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
|
||||
|
||||
defp run_mutation(scope_file, args) do
|
||||
Mix.Task.run("muex", mutation_args(scope_file, args))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def mutation_args(scope_file, extra_args \\ []) do
|
||||
files = read_mutation_scope(scope_file)
|
||||
|
||||
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
|
||||
# into a gate. Extra args come before our defaults, and OptionParser keeps
|
||||
# the first value, so `mix mutate --files lib/foo.ex` targets one file.
|
||||
["--fail-at", "0"] ++
|
||||
extra_args ++
|
||||
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
|
||||
end
|
||||
|
||||
@doc false
|
||||
def read_mutation_scope(scope_file) do
|
||||
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
|
||||
|
||||
files =
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
contents
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
|
||||
"refusing to run the mutation sensor over an unknown scope"
|
||||
)
|
||||
end
|
||||
|
||||
if files == [] do
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
|
||||
)
|
||||
end
|
||||
|
||||
files
|
||||
end
|
||||
|
||||
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
|
||||
# in app/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
|
||||
# land; never lower it. A drop means the change (or an exclusion) is wrong,
|
||||
# not the floor.
|
||||
#
|
||||
# Measured 2026-10-29 (PostgreSQL on localhost:5432, exclusions below):
|
||||
# 94.01% on `main` = 9c0f33e, with boundary enforcement, credo, the mutation
|
||||
# scope and the core-components behavior tests all landed (88.19% after the
|
||||
# web-controller/auth edge-case tests, 87.56% after the editorial-LiveView
|
||||
# ones, 86.97% before them, and 86.79% / 86.17% at earlier exclusion
|
||||
# revisions), identical via `mix test --cover` and via
|
||||
# `mix test --cover --export-coverage default` + `mix test.coverage`. Floor is
|
||||
# the integer below that — so a floor of 94 means "at least 94.00%".
|
||||
#
|
||||
# `test.coverage` deletes stale exports before sampling: `mix test.coverage`
|
||||
# imports *every* .coverdata it finds, so leftover exports from earlier runs
|
||||
# pool into the total and quietly inflate it.
|
||||
#
|
||||
# Excluded are modules the suite cannot or should not exercise; counting them
|
||||
# would move the number without saying anything about app behavior:
|
||||
#
|
||||
# * framework glue generated by `use`:
|
||||
# Firehose.Repo `use Ecto.Repo`
|
||||
# Firehose.Mailer `use Swoosh.Mailer`
|
||||
# FirehoseWeb.Telemetry Phoenix's telemetry supervisor + metric list
|
||||
# FirehoseWeb.ErrorHTML Phoenix error renderers, both delegating to
|
||||
# FirehoseWeb.ErrorJSON Phoenix.Controller.status_message_from_template/1
|
||||
# * FirehoseWeb the `use FirehoseWeb` macro: imports and aliases
|
||||
# only, nothing that executes
|
||||
# * embed_templates wrappers FirehoseWeb.BlogHTML,
|
||||
# FirehoseWeb.UserRegistrationHTML,
|
||||
# FirehoseWeb.UserSettingsHTML: each is `use` +
|
||||
# `embed_templates`, so every function in them is
|
||||
# generated by the macro. Their TEMPLATES are
|
||||
# exercised through the controller tests; the
|
||||
# wrapper modules hold no hand-written code.
|
||||
# NOTE: PageHTML and UserSessionHTML are
|
||||
# deliberately NOT excluded — both carry private
|
||||
# helpers with real logic (blog_label,
|
||||
# post_base_path, local_mail_adapter?), so their
|
||||
# gaps are behavior gaps, not macro noise.
|
||||
# * Firehose.Application start/2 is proven by the suite booting at all (a
|
||||
# broken supervision tree fails every test), and
|
||||
# config_change/3 only runs on a release hot
|
||||
# upgrade, which no test can reach.
|
||||
# * Firehose.Release production release tasks (`mix ecto.migrate` on
|
||||
# Dokku); the suite never runs them
|
||||
# * test scaffolding Firehose.DataCase, FirehoseWeb.ConnCase,
|
||||
# Firehose.AccountsFixtures, Firehose.Test.FakeBlog:
|
||||
# fixtures measure the tests, not the app
|
||||
#
|
||||
# `Firehose.Checks.NoConnShadowing` needs no entry: lib_dev/ is no longer in
|
||||
# elixirc_paths (credo loads it from source), so it is not measurable here.
|
||||
#
|
||||
# Everything else counts — CoreComponents, controllers, LiveViews included:
|
||||
# that is where the behavior-test work shows up as a higher floor.
|
||||
defp test_coverage do
|
||||
[
|
||||
summary: [threshold: 94],
|
||||
ignore_modules: [
|
||||
Firehose.Repo,
|
||||
Firehose.Mailer,
|
||||
Firehose.Release,
|
||||
Firehose.Application,
|
||||
FirehoseWeb,
|
||||
FirehoseWeb.Telemetry,
|
||||
FirehoseWeb.ErrorHTML,
|
||||
FirehoseWeb.ErrorJSON,
|
||||
FirehoseWeb.BlogHTML,
|
||||
FirehoseWeb.UserRegistrationHTML,
|
||||
FirehoseWeb.UserSettingsHTML,
|
||||
Firehose.DataCase,
|
||||
FirehoseWeb.ConnCase,
|
||||
Firehose.AccountsFixtures,
|
||||
Firehose.Test.FakeBlog
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.13.1", "1ce25a7beb750ed3fa2e905962f1f9a679cb585f1da0ae45637d06e385ce2eae", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "39028f0b5a21404661410a6c19275060a9822c9dcca70b7c3e4e4fa50b11da58"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
@@ -13,10 +14,12 @@
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.1", "32b44b468d2c6386b6bf9229617cbf51be025ce4380eacd383a8979cbb8327ef", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "890f251330052dcacafa40c409769e7c42e3876fb0ae450707bde831be7b34ea"},
|
||||
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
@@ -31,6 +34,7 @@
|
||||
"microprints": {:git, "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", "f2a361555310518870ad3c093050169f30ff870c", [branch: "main"]},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"},
|
||||
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
@@ -50,6 +54,7 @@
|
||||
"req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"},
|
||||
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"},
|
||||
"sobelow": {:hex, :sobelow, "0.17.0", "57436898f5b5eac05244ddb301927b1e6521d7de5df3023929d0fe2818d11055", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "02914e7cdc5bd9bdcb1a2be7f08d793fd765f8c238c924f29857d3996d435928"},
|
||||
"swoosh": {:hex, :swoosh, "1.29.0", "b938791267d8a55415344018aa0421d214f9bc36c93cbe27305d3284a853785c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d23ea419c6c67604628c0e93a8d5c6d13bcc442137870671e1b05325b2956a13"},
|
||||
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
defmodule Firehose.CredoConfigTest do
|
||||
@moduledoc """
|
||||
Pins the *policy* in `app/.credo.exs`, not just the file's presence.
|
||||
|
||||
The gate is `mix credo --strict` (the `credo` target in `app/Makefile`, a
|
||||
prerequisite of `check`), so the config decides what every future commit has
|
||||
to satisfy. Plain `ExUnit.Case`: this reads and evaluates a file, it does not
|
||||
touch the database or boot the app's supervision tree.
|
||||
|
||||
The assertions are deliberately coarse - "this check sits in that list",
|
||||
"the disable says why", "the custom check is still loaded" - so they survive
|
||||
credo's own boilerplate churn but not a policy change.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@config_path ".credo.exs"
|
||||
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
|
||||
@custom_check "Firehose.Checks.NoConnShadowing"
|
||||
@custom_check_load_path "lib_dev/firehose/checks/"
|
||||
|
||||
test "cyclomatic complexity is disabled in favour of CRAP" do
|
||||
checks = default_config()[:checks]
|
||||
|
||||
assert @complexity_check in check_modules(checks[:disabled])
|
||||
refute @complexity_check in check_modules(checks[:enabled])
|
||||
end
|
||||
|
||||
test "the disable carries a rationale comment" do
|
||||
rationale = rationale_above(@complexity_check)
|
||||
|
||||
refute rationale == [], "no comment block above the #{@complexity_check} entry"
|
||||
assert Enum.join(rationale, "\n") =~ ~r/crap/i
|
||||
end
|
||||
|
||||
# Regression guard: `mix credo.gen.config` would silently drop both of these,
|
||||
# and the custom check would stop running without one word of warning.
|
||||
test "the custom check and its load path survive" do
|
||||
config = default_config()
|
||||
|
||||
assert @custom_check in check_modules(config[:checks][:enabled])
|
||||
assert @custom_check_load_path in config[:requires]
|
||||
end
|
||||
|
||||
defp default_config do
|
||||
{config, _bindings} = Code.eval_file(@config_path)
|
||||
Enum.find(config.configs, &(&1.name == "default"))
|
||||
end
|
||||
|
||||
defp check_modules(checks) do
|
||||
Enum.map(checks, fn
|
||||
{module, _opts} -> module_name(module)
|
||||
module -> module_name(module)
|
||||
end)
|
||||
end
|
||||
|
||||
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
|
||||
# assertions read better (and diff better) without it.
|
||||
defp module_name(module) do
|
||||
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
|
||||
end
|
||||
|
||||
# The contiguous `#` block immediately above the check's own line. The entry
|
||||
# line is the one that is not itself a comment: a comment mentioning the
|
||||
# check by name must not be mistaken for the entry.
|
||||
defp rationale_above(check_name) do
|
||||
lines = @config_path |> File.read!() |> String.split("\n")
|
||||
|
||||
index =
|
||||
Enum.find_index(lines, fn line ->
|
||||
String.contains?(line, check_name) and not comment?(line)
|
||||
end)
|
||||
|
||||
lines
|
||||
|> Enum.take(index)
|
||||
|> Enum.reverse()
|
||||
|> Enum.take_while(&comment?/1)
|
||||
end
|
||||
|
||||
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
|
||||
end
|
||||
@@ -390,8 +390,57 @@ defmodule Firehose.AccountsTest do
|
||||
end
|
||||
|
||||
describe "inspect/2 for the User module" do
|
||||
test "does not include password" do
|
||||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
# Rendering is `#Firehose.Accounts.User<__meta__: ..., id: 42, ...>`: a
|
||||
# single line that ends with `, ...>`. The tests below pin the stable parts
|
||||
# (module name, one line, redaction, field names and values) rather than
|
||||
# the full string, so a toolchain bump that changes formatting does not
|
||||
# fail them.
|
||||
test "redacts every field the schema marks redacted, by name and by value" do
|
||||
user = %User{
|
||||
id: 42,
|
||||
email: "shape-pin@example.com",
|
||||
password: "plaintext-secret-value",
|
||||
hashed_password: "hashed-secret-value"
|
||||
}
|
||||
|
||||
output = inspect(user)
|
||||
redacted = User.__schema__(:redact_fields)
|
||||
|
||||
# Guard against a vacuous loop if the schema key ever changes meaning.
|
||||
# (`match?` rather than a comparison against `[]`: the type checker
|
||||
# proves this schema's literal list non-empty and warns on the compare.)
|
||||
assert match?([_ | _], redacted)
|
||||
# The two secrets this schema declares; ties the loop to real fields.
|
||||
assert :password in redacted
|
||||
assert :hashed_password in redacted
|
||||
|
||||
for field <- redacted do
|
||||
refute output =~ Atom.to_string(field)
|
||||
refute output =~ Map.fetch!(user, field)
|
||||
end
|
||||
end
|
||||
|
||||
test "renders a single struct line with non-redacted fields and values" do
|
||||
user = %User{
|
||||
id: 42,
|
||||
email: "shape-pin@example.com",
|
||||
password: "plaintext-secret-value",
|
||||
hashed_password: "hashed-secret-value"
|
||||
}
|
||||
|
||||
output = inspect(user)
|
||||
redacted = User.__schema__(:redact_fields)
|
||||
|
||||
assert String.starts_with?(output, "#Firehose.Accounts.User<")
|
||||
assert String.ends_with?(output, ">")
|
||||
refute output =~ "\n"
|
||||
|
||||
assert output =~ "id: 42"
|
||||
assert output =~ ~s(email: "shape-pin@example.com")
|
||||
|
||||
for %{field: field} <- User.__info__(:struct), field not in redacted do
|
||||
assert output =~ Atom.to_string(field)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
defmodule Firehose.BoundaryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mix.Dep.Lock
|
||||
|
||||
# Boundary enforcement itself is a compile-time property: the pin on it is the
|
||||
# warnings-fatal compile in `make verify` (and the recorded violation
|
||||
# demonstration in the yak notes). These tests pin the *declarations* that
|
||||
# compile gate reads, so a later edit that quietly re-opens the web -> Repo
|
||||
# hole shows up as a failure here instead of only as an architectural drift.
|
||||
#
|
||||
# They read the `Boundary` module attribute that `use Boundary` persists. That
|
||||
# is the same attribute boundary's own classifier reads
|
||||
# (deps/boundary/lib/boundary/definition.ex: `Module.register_attribute(...,
|
||||
# Boundary, persist: true)`), so this is the declaration as compiled, not a
|
||||
# grep of the source text.
|
||||
describe "boundary dependency" do
|
||||
test "boundary is a direct dependency, not one inherited from ex_crap" do
|
||||
assert {:boundary, requirement, opts} =
|
||||
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
|
||||
|
||||
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
|
||||
assert Version.match?(locked_version(:boundary), requirement)
|
||||
end
|
||||
end
|
||||
|
||||
describe "a sibling Repo boundary" do
|
||||
test "Firehose.Repo is its own top-level boundary with no dependencies" do
|
||||
definition = boundary(Firehose.Repo)
|
||||
|
||||
assert definition.opts[:top_level?] == true,
|
||||
"Firehose.Repo must be a sibling of Firehose/FirehoseWeb, not nested inside Firehose"
|
||||
|
||||
assert deps(Firehose.Repo) == [],
|
||||
"the Repo is the bottom of the graph: it may depend on nothing"
|
||||
end
|
||||
|
||||
test "Firehose may use Firehose.Repo" do
|
||||
assert Firehose.Repo in deps(Firehose)
|
||||
end
|
||||
|
||||
test "FirehoseWeb may not use Firehose.Repo" do
|
||||
refute Firehose.Repo in deps(FirehoseWeb),
|
||||
"web -> Repo is the hole this boundary closes; only contexts may touch the Repo"
|
||||
end
|
||||
end
|
||||
|
||||
describe "the context boundary's surface" do
|
||||
test "Firehose exports everything, so the web tier can keep using context modules" do
|
||||
assert boundary(Firehose).opts[:exports] == :all
|
||||
end
|
||||
end
|
||||
|
||||
# Application boot is the only thing that reaches from the context tier into
|
||||
# the web tier. Making it its own top-level boundary (boundary's README
|
||||
# prescribes exactly this for `mix phx.new` layouts) is what keeps the graph
|
||||
# acyclic; the alternative -- FirehoseWeb in Firehose's deps -- is rejected by
|
||||
# boundary with "dependency cycle found: Firehose -> FirehoseWeb -> Firehose".
|
||||
describe "the application boundary" do
|
||||
test "Firehose.Application depends on both tiers, and neither tier on the other's boot code" do
|
||||
assert boundary(Firehose.Application).opts[:top_level?] == true
|
||||
|
||||
assert Enum.sort(deps(Firehose.Application)) == [Firehose, FirehoseWeb]
|
||||
|
||||
refute FirehoseWeb in deps(Firehose),
|
||||
"Firehose depending on FirehoseWeb is the dependency cycle"
|
||||
end
|
||||
end
|
||||
|
||||
describe "enforcement wiring" do
|
||||
# A compiler that is registered after :elixir looks wired but never reports
|
||||
# anything, so the gate would be decoration. Position is the property.
|
||||
test "the boundary compiler runs before :elixir in this environment" do
|
||||
compilers = Mix.Project.config()[:compilers]
|
||||
|
||||
assert :boundary in compilers, "boundary compiler is not wired in #{Mix.env()}"
|
||||
|
||||
assert Enum.find_index(compilers, &(&1 == :boundary)) <
|
||||
Enum.find_index(compilers, &(&1 == :elixir)),
|
||||
"boundary must be listed before :elixir: it hooks after_compiler(:elixir, ...)"
|
||||
end
|
||||
end
|
||||
|
||||
defp boundary(module) do
|
||||
assert [definition] = module.__info__(:attributes)[Boundary],
|
||||
"#{inspect(module)} declares no boundary"
|
||||
|
||||
definition
|
||||
end
|
||||
|
||||
defp deps(module), do: Keyword.get(boundary(module).opts, :deps, [])
|
||||
|
||||
defp locked_version(app) do
|
||||
Lock.read()
|
||||
|> Map.fetch!(app)
|
||||
|> elem(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,226 @@
|
||||
defmodule FirehoseWeb.CoreComponentsTest do
|
||||
# Pure render tests: no conn, no database, so plain ExUnit.Case (the same
|
||||
# choice layouts_test.exs makes) instead of the ConnCase sandbox.
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias FirehoseWeb.CoreComponents
|
||||
|
||||
@checkbox_assigns %{
|
||||
type: "checkbox",
|
||||
name: "prefs[news]",
|
||||
id: "prefs_news",
|
||||
label: "News",
|
||||
value: "true",
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
@select_assigns %{
|
||||
type: "select",
|
||||
name: "role",
|
||||
id: "role",
|
||||
label: "Role",
|
||||
prompt: "Pick one",
|
||||
options: [{"Admin", "admin"}, {"Editor", "editor"}],
|
||||
value: "admin",
|
||||
multiple: false,
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
@textarea_assigns %{
|
||||
type: "textarea",
|
||||
name: "body",
|
||||
id: "body",
|
||||
label: "Body",
|
||||
value: "hello world",
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
describe "input/1 checkbox" do
|
||||
test "a checked box renders checked when the value is true" do
|
||||
html = render_checkbox(%{})
|
||||
|
||||
assert attr(html, "input[type='checkbox']", "name") == "prefs[news]"
|
||||
assert attr(html, "input[type='checkbox']", "value") == "true"
|
||||
assert attr(html, "input[type='checkbox']", "checked") == ""
|
||||
assert text(html) =~ "News"
|
||||
end
|
||||
|
||||
test "an unchecked box still posts false for the field" do
|
||||
html = render_checkbox(%{value: "false"})
|
||||
|
||||
assert attr(html, "input[type='checkbox']", "checked") == nil
|
||||
|
||||
# Markup is the behaviour here: the hidden field carrying the same name is
|
||||
# what makes an unchecked box post "false" instead of posting nothing.
|
||||
assert attr(html, "input[type='hidden']", "name") == "prefs[news]"
|
||||
assert attr(html, "input[type='hidden']", "value") == "false"
|
||||
end
|
||||
|
||||
test "shows the error message when the field has errors" do
|
||||
html = render_checkbox(%{errors: ["can't be blank"]})
|
||||
|
||||
assert text(html) =~ "can't be blank"
|
||||
end
|
||||
end
|
||||
|
||||
describe "input/1 select" do
|
||||
test "renders the prompt, every option, and preselects the current value" do
|
||||
html = render_select(%{})
|
||||
|
||||
assert text(html) =~ "Pick one"
|
||||
assert text(html) =~ "Admin"
|
||||
assert text(html) =~ "Editor"
|
||||
# The outcome, not the serialisation: the option carrying the current
|
||||
# value is selected, the other one is not.
|
||||
assert attr(html, "option[value='admin']", "selected") == ""
|
||||
assert attr(html, "option[value='editor']", "selected") == nil
|
||||
end
|
||||
|
||||
test "marks the select erroneous and shows the message when given errors" do
|
||||
html = render_select(%{errors: ["is invalid"]})
|
||||
|
||||
assert text(html) =~ "is invalid"
|
||||
assert "select-error" in classes(html, "select")
|
||||
end
|
||||
end
|
||||
|
||||
describe "input/1 textarea" do
|
||||
test "keeps the value inside the textarea" do
|
||||
html = render_textarea(%{value: "hello world"})
|
||||
|
||||
# normalize_value/2 prefixes the content with a newline, so compare the
|
||||
# trimmed text: what matters is the value, not the surrounding whitespace.
|
||||
assert String.trim(text(html, "textarea")) == "hello world"
|
||||
end
|
||||
|
||||
test "renders an empty textarea for a nil value instead of the text nil" do
|
||||
html = render_textarea(%{value: nil})
|
||||
|
||||
assert String.trim(text(html, "textarea")) == ""
|
||||
refute html =~ "nil"
|
||||
end
|
||||
|
||||
test "keeps a leading newline in the value" do
|
||||
# HTML drops one leading newline of a <textarea> before the browser sees
|
||||
# it, so normalize_value/2 prefixes the content with one: a value that
|
||||
# starts with a newline survives, and one that does not gain one.
|
||||
html = render_textarea(%{value: "\nsecond line"})
|
||||
|
||||
assert text(html, "textarea") == "\nsecond line"
|
||||
end
|
||||
|
||||
test "marks the textarea erroneous and shows the message when given errors" do
|
||||
html = render_textarea(%{errors: ["is too short"]})
|
||||
|
||||
assert text(html) =~ "is too short"
|
||||
assert "textarea-error" in classes(html, "textarea")
|
||||
end
|
||||
end
|
||||
|
||||
describe "button/1" do
|
||||
test "renders a button element when given no destination" do
|
||||
html = render_button(%{})
|
||||
|
||||
assert count(html, "button") == 1
|
||||
assert count(html, "a") == 0
|
||||
assert String.trim(text(html)) == "Save"
|
||||
end
|
||||
|
||||
test "renders a link to the destination when given an href, keeping the label" do
|
||||
html = render_button(%{rest: %{href: "/somewhere"}})
|
||||
|
||||
assert attr(html, "a", "href") == "/somewhere"
|
||||
assert String.trim(text(html)) == "Save"
|
||||
assert count(html, "button") == 0
|
||||
end
|
||||
|
||||
test "renders a live-navigation link when given navigate" do
|
||||
html = render_button(%{rest: %{navigate: "/elsewhere"}})
|
||||
|
||||
# data-phx-link is the only thing distinguishing a live-navigation link
|
||||
# from a full-page one, so it is the behaviour to pin here.
|
||||
assert attr(html, "a", "data-phx-link") == "redirect"
|
||||
assert attr(html, "a", "href") == "/elsewhere"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_checkbox(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@checkbox_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_select(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@select_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_textarea(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@textarea_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_button(overrides) do
|
||||
assigns =
|
||||
Map.merge(
|
||||
%{
|
||||
variant: nil,
|
||||
class: "",
|
||||
rest: %{},
|
||||
# A slot, as HEEx builds it: a list of entries whose inner_block takes
|
||||
# (argument, changed).
|
||||
inner_block: [%{inner_block: fn _, _ -> "Save" end}]
|
||||
},
|
||||
overrides
|
||||
)
|
||||
|
||||
render_component(&CoreComponents.button/1, assigns)
|
||||
end
|
||||
|
||||
# Every attribute assertion goes through here, so the LazyHTML mechanism lives
|
||||
# in one place: one value per matched element, nil when the element (or the
|
||||
# attribute) is absent, "" for a valueless attribute such as `checked`.
|
||||
defp attr(html, selector, name) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.attribute(name)
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
# The class tokens of every matched element, for the "this field is marked
|
||||
# erroneous" assertions.
|
||||
defp classes(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.attribute("class")
|
||||
|> Enum.flat_map(&String.split/1)
|
||||
end
|
||||
|
||||
defp nodes(html, selector) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(selector)
|
||||
end
|
||||
|
||||
defp count(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> Enum.count()
|
||||
end
|
||||
|
||||
defp text(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.text()
|
||||
end
|
||||
|
||||
defp text(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.text()
|
||||
end
|
||||
end
|
||||
@@ -20,6 +20,29 @@ defmodule FirehoseWeb.BlogTest do
|
||||
body = visit_blog_page(conn, "engineering", "/hello-world")
|
||||
assert body =~ "Hello World"
|
||||
end
|
||||
|
||||
test "GET /blog/engineering?page=2 shows the next page of older posts", %{conn: conn} do
|
||||
# Page size is 10 (`Blogex.Blog.paginate/2`, blogex/lib/blogex/blog.ex:121)
|
||||
# and the test env lists drafts (`show_drafts: true`,
|
||||
# app/config/config.exs), so the 34 visible engineering posts (the
|
||||
# 2099-dated future post is excluded by `all_posts/0`) fill 4 pages.
|
||||
# Page 2, date-descending, runs 2026-07-20 .. 2026-05-14.
|
||||
body = visit_blog_page(conn, "engineering", "?page=2")
|
||||
|
||||
# The pager reports the page the reader asked for, not page 1
|
||||
# (markup: blogex/lib/blogex/components.ex).
|
||||
assert body =~ ~r/Page 2 of \d+/
|
||||
|
||||
# "Synthetic TDD" (2026-07-13) sits mid-page-2: five posts of headroom
|
||||
# before new publications would push it to page 3. Matched as an anchor
|
||||
# because the description of the page-2 post above it also says
|
||||
# "Synthetic TDD".
|
||||
assert body =~ ~s|>Synthetic TDD</a>|
|
||||
|
||||
# The newest post is unambiguously on page 1, so page 2 is a genuinely
|
||||
# different set of posts rather than page 1 relabelled.
|
||||
refute body =~ "I reduced the attack surface"
|
||||
end
|
||||
end
|
||||
|
||||
describe "release notes blog (HTML)" do
|
||||
@@ -79,6 +102,56 @@ defmodule FirehoseWeb.BlogTest do
|
||||
|
||||
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
|
||||
end
|
||||
|
||||
test "rendered post page carries the screen-reader hint for external links", %{conn: conn} do
|
||||
body =
|
||||
conn |> get("/blog/engineering/delayed-gratification-in-ai-summer") |> html_response(200)
|
||||
|
||||
assert body =~ ~s|<span class="blogex-external-hint"> (opens in a new tab)</span>|
|
||||
end
|
||||
end
|
||||
|
||||
describe "internal links in compiled posts" do
|
||||
# `Blogex.Blog._validate_links/2` validates `post.body` after
|
||||
# NimblePublisher rendered it to HTML. If extraction cannot read that HTML,
|
||||
# every post looks link-free and compile-time validation is vacuous - which
|
||||
# is the failure the real posts below pin against.
|
||||
defp posts_with_internal_links do
|
||||
Enum.filter(Firehose.EngineeringBlog.all_posts(), fn post ->
|
||||
Blogex.LinkValidator.extract_links(post.body) != []
|
||||
end)
|
||||
end
|
||||
|
||||
test "the validator sees internal links in real compiled posts" do
|
||||
refute posts_with_internal_links() == []
|
||||
end
|
||||
|
||||
test "the post that links to swim-the-test exposes that link" do
|
||||
post =
|
||||
Enum.find(posts_with_internal_links(), &(&1.id == "delayed-gratification-in-ai-summer"))
|
||||
|
||||
assert post, "expected the compiled post to be visible with internal links extracted"
|
||||
|
||||
assert "/blog/engineering/swim-the-test" in Blogex.LinkValidator.extract_links(post.body)
|
||||
end
|
||||
|
||||
test "no internal link in a real compiled post is invisible to extraction" do
|
||||
# Deliberately permissive scan (any <a> whose href appears in any
|
||||
# position, quoted or not): every /blog/ link it finds must also come out
|
||||
# of extract_links/1, or a bad one could never be reported.
|
||||
links_in_body = fn body ->
|
||||
~r/<a\s[^>]*?href=["']?(\/blog\/[^"'\s>]+)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, link] -> link end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
for post <- Firehose.EngineeringBlog.all_posts(),
|
||||
link <- links_in_body.(post.body) do
|
||||
assert link in Blogex.LinkValidator.extract_links(post.body),
|
||||
"#{post.id}: #{link} is in the compiled body but not extracted"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "input validation" do
|
||||
|
||||
@@ -21,6 +21,17 @@ defmodule FirehoseWeb.PageControllerTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /contact" do
|
||||
test "renders the contact page inside the app layout", %{conn: conn} do
|
||||
body = conn |> get(~p"/contact") |> html_response(200)
|
||||
|
||||
# Page content (contact.html.heex) ...
|
||||
assert body =~ "Get in touch"
|
||||
# ... wrapped by the app layout, which is where the site nav lives.
|
||||
assert body =~ ~s|/blog/engineering|
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET / when authenticated" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
|
||||
@@ -61,13 +61,37 @@ defmodule FirehoseWeb.UserSessionControllerTest do
|
||||
assert html =~ "Keep me logged in on this device"
|
||||
end
|
||||
|
||||
test "raises error for invalid token", %{conn: conn} do
|
||||
# The two ways a magic link can be dead arrive here through different arms
|
||||
# of `Firehose.Accounts.get_user_by_magic_link_token/1`: a token that is
|
||||
# not base64 at all fails to decode (`UserToken.verify_magic_link_token_query/1`
|
||||
# returns `:error`), while a well-formed token simply matches no row. Both
|
||||
# must land the visitor back on the log-in page with the error flash, not
|
||||
# raise or 404.
|
||||
test "sends the visitor back to log-in when the magic link token is not valid base64",
|
||||
%{conn: conn} do
|
||||
# 13 characters is not decodable with `padding: false`; the 0 hits on the
|
||||
# magic-link `:error` line in cover/ show this test is what covers it.
|
||||
assert :error = Base.url_decode64("invalid-token", padding: false)
|
||||
|
||||
response = conn |> get(~p"/users/log-in/invalid-token")
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
|
||||
test "sends the visitor back to log-in for a decodable but unknown token", %{conn: conn} do
|
||||
# The assertion keeps this case distinct from the not-base64 one above:
|
||||
# if this token ever stopped decoding, the two tests would silently pin
|
||||
# the same arm.
|
||||
assert {:ok, _decoded} = Base.url_decode64("invalidtoken", padding: false)
|
||||
|
||||
response = conn |> get(~p"/users/log-in/invalidtoken")
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - email and password" do
|
||||
|
||||
@@ -87,6 +87,41 @@ defmodule FirehoseWeb.EditorDashboardLiveTest do
|
||||
refute html =~ "Live Post"
|
||||
end
|
||||
|
||||
test "switching Drafts -> Scheduled -> Drafts shows the matching panel", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/editor/dashboard")
|
||||
|
||||
# Both panels are always in the DOM, and only the inactive one is marked
|
||||
# `hidden`: the panel's own class is the only server-side signal of what
|
||||
# the reader sees. The panel-scoped content assertions below keep this
|
||||
# from being a class check alone.
|
||||
assert has_element?(view, "#drafts-tab")
|
||||
refute has_element?(view, "#drafts-tab.hidden")
|
||||
assert has_element?(view, "#scheduled-tab.hidden")
|
||||
|
||||
drafts_html = view |> element("#drafts-tab") |> render()
|
||||
assert drafts_html =~ "Draft Post"
|
||||
refute drafts_html =~ "Scheduled Post"
|
||||
|
||||
view |> element(~s(button[phx-value-tab="scheduled"])) |> render_click()
|
||||
|
||||
assert has_element?(view, "#scheduled-tab")
|
||||
refute has_element?(view, "#scheduled-tab.hidden")
|
||||
assert has_element?(view, "#drafts-tab.hidden")
|
||||
|
||||
scheduled_html = view |> element("#scheduled-tab") |> render()
|
||||
assert scheduled_html =~ "Scheduled Post"
|
||||
refute scheduled_html =~ "Draft Post"
|
||||
|
||||
view |> element(~s(button[phx-value-tab="drafts"])) |> render_click()
|
||||
|
||||
refute has_element?(view, "#drafts-tab.hidden")
|
||||
assert has_element?(view, "#scheduled-tab.hidden")
|
||||
|
||||
drafts_again_html = view |> element("#drafts-tab") |> render()
|
||||
assert drafts_again_html =~ "Draft Post"
|
||||
refute drafts_again_html =~ "Scheduled Post"
|
||||
end
|
||||
|
||||
test "renders with app layout including menu", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
|
||||
# Verify the app layout is applied by checking for menu elements
|
||||
|
||||
@@ -3,6 +3,7 @@ alias FirehoseWeb.MicroprintsLive
|
||||
defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
use FirehoseWeb.ConnCase, async: true
|
||||
|
||||
import FirehoseWeb.MicroprintsCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "scan_source_files/0" do
|
||||
@@ -81,18 +82,15 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
# Expand file A
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# Verify file A shows "Collapse" button (it's expanded)
|
||||
html = render(view)
|
||||
assert html =~ ~s(phx-value-path="#{file_a}")
|
||||
assert html =~ "Collapse"
|
||||
|
||||
# Highlight a line in file B by dispatching the highlight_line event
|
||||
# (microprint renders multiple rect segments per line, element() can't pick one)
|
||||
render_click(view, "highlight_line", %{"line" => "1", "path" => file_b})
|
||||
# Highlight a line in file B
|
||||
click_microprint_rect(view, file_b, 1)
|
||||
|
||||
# file A should be collapsed when highlighting a different file
|
||||
html = render(view)
|
||||
@@ -112,9 +110,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
# Expand file A
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# Capture file A's source content from the rendered HTML
|
||||
html_a = render(view)
|
||||
@@ -123,9 +119,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
"File A source should be visible when expanded"
|
||||
|
||||
# Expand file B (should auto-collapse A)
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_b}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_b)
|
||||
|
||||
# Capture file B's source content
|
||||
html_b = render(view)
|
||||
@@ -144,6 +138,34 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "collapsing an expanded file" do
|
||||
test "hides the source lines and brings the Expand control back", %{conn: conn} do
|
||||
# The URL route is used rather than clicking Expand: `restore_state_from_params/2`
|
||||
# validates the param against the scanned cards (Enum.find over @microprints),
|
||||
# so a real scanned path is required either way - see the "unknown expanded
|
||||
# path in URL is ignored" test for the other half of that contract.
|
||||
path = MicroprintsLive.scan_source_files() |> List.first()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints?expanded=#{path}")
|
||||
|
||||
assert has_element?(view, "#line-1"),
|
||||
"restored expansion should render the file's source lines"
|
||||
|
||||
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Collapse")
|
||||
|
||||
collapse_file(view, path)
|
||||
|
||||
refute has_element?(view, "#line-1"),
|
||||
"collapsing should stop rendering the source viewer"
|
||||
|
||||
refute render(view) =~ "Collapse",
|
||||
"no card should still be expanded once the only expanded one is collapsed"
|
||||
|
||||
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Expand"),
|
||||
"the card's control should be Expand again"
|
||||
end
|
||||
end
|
||||
|
||||
describe "app layout with menu" do
|
||||
test "renders the microprints page", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/microprints")
|
||||
@@ -190,9 +212,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# URL should contain expanded param after clicking Expand
|
||||
html = render(view)
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
defmodule FirehoseWeb.SourceViewerTest do
|
||||
use FirehoseWeb.ConnCase, async: true
|
||||
|
||||
import FirehoseWeb.MicroprintsCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias FirehoseWeb.MicroprintsLive
|
||||
|
||||
defp expand_file(view, path) do
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{path}\"]", "Expand")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
defp click_microprint_rect(view, path, line) do
|
||||
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
|
||||
end
|
||||
|
||||
describe "line elements" do
|
||||
test "render with unique ids when file is expanded", %{conn: conn} do
|
||||
files = MicroprintsLive.scan_source_files()
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
defmodule FirehoseWeb.ThemeStylesheetTest do
|
||||
@moduledoc """
|
||||
Guards the theme's source files against the two defects the theme yak records.
|
||||
|
||||
daisyUI 5 stores a *complete* colour in every `--color-*` custom property
|
||||
(`--color-base-content: oklch(25% 0.015 50)`), so `oklch(var(--color-…))`
|
||||
re-wraps a complete value and the browser drops the whole declaration. The
|
||||
visible symptom was a transparent chip background and unstyled links.
|
||||
|
||||
Each theme's `prefersdark` option makes daisyUI emit an *unscoped*
|
||||
`@media (prefers-color-scheme: dark) { :root { … } }`, which lands after the
|
||||
light block and wins on equal specificity: an explicit `data-theme="light"`
|
||||
rendered dark on a dark-OS browser. Theme selection now lives in the inline
|
||||
script in `root.html.heex`, which always writes an explicit `data-theme`
|
||||
before first paint, so no stylesheet rule may follow the OS on its own.
|
||||
|
||||
These read the two source files as text: they are regression guards, not
|
||||
behavioural tests. The authoritative check for *rendered* colour and theme
|
||||
selection is the factory probe (`mastra-factory/scripts/theme-probe.mjs`),
|
||||
which needs a browser; `make verify` never parses or renders CSS.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# `__DIR__`-relative so the assertions do not depend on the working directory.
|
||||
@stylesheet Path.expand("../../assets/css/app.css", __DIR__)
|
||||
@root_layout Path.expand("../../lib/firehose_web/components/layouts/root.html.heex", __DIR__)
|
||||
@layouts Path.expand("../../lib/firehose_web/components/layouts.ex", __DIR__)
|
||||
|
||||
# Measured by the factory probe (chromium, 2026-10-29) for `.blogex-post-meta`
|
||||
# in the light theme: base-content over base-100 composited at 0.6 opacity is
|
||||
# 4.16:1, below the 4.5:1 acceptance floor. The alpha the floor needs is
|
||||
# 0.625; anything below that fails.
|
||||
@meta_min_opacity 0.625
|
||||
|
||||
# Same probe, same day, for `--color-primary` as *text* on `--color-base-100`
|
||||
# in the light theme - what pagination links, body links and the post-title
|
||||
# hover now use. 58% lightness measured 4.18:1; 56% is 4.54:1, too thin a
|
||||
# margin; 54% is 4.93:1 (and 4.52:1 on base-200, the lightest chip ground).
|
||||
@light_primary_max_lightness 0.54
|
||||
|
||||
describe "colour tokens" do
|
||||
test "theme colour tokens are used as complete values" do
|
||||
offenders =
|
||||
@stylesheet
|
||||
|> read()
|
||||
|> String.split("\n")
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.filter(fn {line, _number} -> line =~ ~r/oklch\(\s*var\(--color-/ end)
|
||||
|
||||
assert offenders == [],
|
||||
"""
|
||||
oklch(var(--color-…)) is invalid: the --color-* properties already hold a
|
||||
complete oklch(…) value, so the declaration is dropped. Use var(--color-…)
|
||||
directly, or color-mix() where a derived colour is genuinely needed.
|
||||
|
||||
Offending line(s):
|
||||
#{Enum.map_join(offenders, "\n", fn {line, number} -> " #{number}: #{String.trim(line)}" end)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
describe "theme selection" do
|
||||
test "the head script always writes an explicit data-theme" do
|
||||
script = theme_script()
|
||||
|
||||
# "system" is resolved here, from the OS preference, before first paint.
|
||||
assert script =~ ~r/matchMedia\(\s*["']\(prefers-color-scheme:\s*dark\)["']\s*\)/
|
||||
assert script =~ ~r/setAttribute\(\s*["']data-theme["']/
|
||||
# Removing the attribute is what let the unstyled root fall back to the
|
||||
# OS-driven media block, so "system" must never be expressed that way.
|
||||
refute script =~ "removeAttribute"
|
||||
end
|
||||
|
||||
test "an OS preference change re-resolves only while 'system' is the choice" do
|
||||
assert theme_script() =~
|
||||
~r/addEventListener\(\s*["']change["'][\s\S]*?stored\(\)\s*===\s*["']system["']/
|
||||
end
|
||||
|
||||
test "the toggle knob follows the choice, not the resolved palette" do
|
||||
# A system choice can resolve to the dark palette; the indicator must still
|
||||
# sit on the system icon, or the toggle lies about what is selected.
|
||||
layouts = read(@layouts)
|
||||
|
||||
assert layouts =~ "[[data-theme-choice=light]_&]:left-1/3"
|
||||
assert layouts =~ "[[data-theme-choice=dark]_&]:left-2/3"
|
||||
refute layouts =~ "[[data-theme=light]_&]"
|
||||
refute layouts =~ "[[data-theme=dark]_&]"
|
||||
end
|
||||
|
||||
# The human's decision (REVIEW.md, Q1): "default to light theme, and make
|
||||
# setting explicit". Light is what an unset preference resolves to; "system"
|
||||
# is an explicit choice, not the default.
|
||||
test "no stored preference means the light theme" do
|
||||
assert theme_script() =~ ~r/apply\(\s*stored\(\)\s*\|\|\s*["']light["']\s*\)/
|
||||
end
|
||||
|
||||
test "no theme follows the OS preference on its own" do
|
||||
# `prefersdark: true` mints the unscoped media block that outranked the
|
||||
# explicit attribute. Selection is the script's job; the stylesheet's
|
||||
# default (light) must apply whenever an attribute is absent.
|
||||
assert theme_option("light", "prefersdark") == "false"
|
||||
assert theme_option("dark", "prefersdark") == "false"
|
||||
end
|
||||
end
|
||||
|
||||
describe "post meta contrast" do
|
||||
test "the post meta is dimmed no further than the contrast floor allows" do
|
||||
opacity = declaration(".blogex-post-meta", "opacity")
|
||||
|
||||
assert opacity >= @meta_min_opacity,
|
||||
"""
|
||||
.blogex-post-meta has opacity #{opacity}, which composites base-content over
|
||||
base-100 below the 4.5:1 floor (probe: 0.6 -> 4.16:1, 0.625 -> 4.51:1).
|
||||
Raise it, or the meta line is unreadable in the light theme.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
describe "light theme link colour" do
|
||||
test "primary is dark enough to be text on base-100" do
|
||||
lightness = lightness(theme_option("light", "--color-primary"))
|
||||
|
||||
assert lightness <= @light_primary_max_lightness,
|
||||
"""
|
||||
The light theme's --color-primary has #{lightness} lightness, so link text
|
||||
(pagination, body links, the post-title hover) composites below 4.5:1 on
|
||||
base-100: the probe measured 4.18:1 at 0.58. 0.54 measures 4.93:1.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp read(path), do: File.read!(path)
|
||||
|
||||
# OKLCH lightness as a 0..1 fraction; daisyUI writes either `54%` or `0.54`.
|
||||
defp lightness(value) do
|
||||
case Regex.run(~r/^oklch\(\s*([\d.]+)\s*(%?)/, value) do
|
||||
[_, number, "%"] -> number |> Float.parse() |> elem(0) |> Kernel./(100)
|
||||
[_, number, ""] -> number |> Float.parse() |> elem(0)
|
||||
_ -> flunk("cannot read the lightness of #{inspect(value)}")
|
||||
end
|
||||
end
|
||||
|
||||
# The declarations of a single plain rule (`selector { … }`).
|
||||
defp declaration(selector, property) do
|
||||
selector
|
||||
|> rule_body()
|
||||
|> then(fn body ->
|
||||
case Regex.run(~r/^\s*#{property}:\s*([^;]+);/m, body) do
|
||||
[_, value] -> value |> String.trim() |> String.to_float()
|
||||
nil -> flunk("no #{property} in #{selector}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp rule_body(selector) do
|
||||
case Regex.run(~r/#{Regex.escape(selector)}\s*\{([^}]*)\}/, read(@stylesheet)) do
|
||||
[_, body] -> body
|
||||
nil -> flunk("no rule for #{selector} in #{@stylesheet}")
|
||||
end
|
||||
end
|
||||
|
||||
# The inline script in <head>, not the deferred `app.js` tag.
|
||||
defp theme_script do
|
||||
@root_layout
|
||||
|> read()
|
||||
|> then(&Regex.scan(~r/<script[^>]*>(.*?)<\/script>/s, &1, capture: :all_but_first))
|
||||
|> List.flatten()
|
||||
|> Enum.find(fn body -> body =~ "data-theme" end) ||
|
||||
flunk("no inline theme script in #{@root_layout}")
|
||||
end
|
||||
|
||||
# The body of the `@plugin "../vendor/daisyui-theme" { … }` block for `name`.
|
||||
defp theme_block(name) do
|
||||
@stylesheet
|
||||
|> read()
|
||||
|> String.split(~s(@plugin "../vendor/daisyui-theme" {))
|
||||
|> Enum.find(fn block ->
|
||||
Enum.any?(String.split(block, "\n"), &(&1 |> String.trim() == ~s(name: "#{name}";)))
|
||||
end) || flunk("no daisyui-theme block named #{inspect(name)} in #{@stylesheet}")
|
||||
end
|
||||
|
||||
defp theme_option(name, option) do
|
||||
case Regex.run(~r/^\s*#{option}:\s*([^;]+);/m, theme_block(name)) do
|
||||
[_, value] -> String.trim(value)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
alias Firehose.Accounts
|
||||
alias Firehose.Accounts.Scope
|
||||
alias FirehoseWeb.UserAuth
|
||||
alias Phoenix.LiveView.Socket
|
||||
|
||||
import Firehose.AccountsFixtures
|
||||
|
||||
@@ -27,6 +28,13 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
end
|
||||
|
||||
test "stores a live socket id derived from the session token", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
token = get_session(conn, :user_token)
|
||||
|
||||
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
|
||||
end
|
||||
|
||||
test "clears everything previously stored in the session", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
|
||||
refute get_session(conn, :to_be_removed)
|
||||
@@ -113,6 +121,21 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
refute Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
|
||||
test "broadcasts a disconnect on the session's live socket id", %{conn: conn, user: user} do
|
||||
logged_in_conn = UserAuth.log_in_user(conn, user)
|
||||
token = get_session(logged_in_conn, :user_token)
|
||||
live_socket_id = get_session(logged_in_conn, :live_socket_id)
|
||||
FirehoseWeb.Endpoint.subscribe(live_socket_id)
|
||||
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, live_socket_id)
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
|
||||
refute_receive %Phoenix.Socket.Broadcast{topic: ^live_socket_id}
|
||||
end
|
||||
|
||||
test "works even if user is already logged out", %{conn: conn} do
|
||||
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
|
||||
refute get_session(conn, :user_token)
|
||||
@@ -183,6 +206,56 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
assert new_signed_token != signed_token
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
end
|
||||
|
||||
test "reissues the live socket id together with the token", %{conn: conn, user: user} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
||||
token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
old_live_socket_id = get_session(logged_in_conn, :live_socket_id)
|
||||
|
||||
offset_user_token(token, -10, :day)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_session(:live_socket_id, old_live_socket_id)
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
assert new_token = get_session(conn, :user_token)
|
||||
refute new_token == token
|
||||
|
||||
assert get_session(conn, :live_socket_id) ==
|
||||
"users_sessions:#{Base.url_encode64(new_token)}"
|
||||
|
||||
refute get_session(conn, :live_socket_id) == old_live_socket_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "live view sockets" do
|
||||
# A real websocket disconnect cannot be driven through Phoenix.LiveViewTest:
|
||||
# the "disconnect" broadcast is handled by Phoenix.Socket.__info__/2 on the
|
||||
# transport process (deps/phoenix/lib/phoenix/socket.ex:571), and the test
|
||||
# harness (phoenix_live_view/test/client_proxy.ex) never subscribes to the
|
||||
# socket id topic. What can be pinned is the contract the transport relies
|
||||
# on: the value in the session is the topic Phoenix.LiveView.Socket.id/1
|
||||
# returns for the session the websocket receives (string keys).
|
||||
test "the id written to the session is the topic Socket.id/1 reads", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
live_socket_id = get_session(conn, :live_socket_id)
|
||||
|
||||
socket_session =
|
||||
Map.new(conn.private[:plug_session], fn {key, value} -> {to_string(key), value} end)
|
||||
|
||||
socket = %Socket{
|
||||
private: %{connect_info: %{session: socket_session}}
|
||||
}
|
||||
|
||||
assert Socket.id(socket) == live_socket_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_sudo_mode/2" do
|
||||
@@ -290,4 +363,27 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
|
||||
describe "a session token that no longer exists" do
|
||||
# This is the plug-layer contract that keeps UserAuth's LiveView-side
|
||||
# defences unreachable in this app: the router wraps /editor/dashboard in
|
||||
# `pipe_through [:browser, :require_authenticated_user]`, so the plug
|
||||
# redirects before `on_mount {UserAuth, :ensure_authenticated}` can run and
|
||||
# its halt arm never fires for a stale token. The same goes for the nil
|
||||
# scope fallbacks in `scope_from_session/1`.
|
||||
test "is treated as logged out", %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
token = get_session(conn, :user_token)
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
|
||||
:ok = Accounts.delete_user_session_token(token)
|
||||
|
||||
response = conn |> get(~p"/users/settings")
|
||||
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"You must log in to access this page."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
defmodule Firehose.MutationConfigTest do
|
||||
# Same wiring pins as blogex, plus the app-specific one: because the app
|
||||
# scope is deliberately empty (Q45), the aliases must fail loudly. A sensor
|
||||
# that reports a score for a run that tested nothing is worse than no sensor.
|
||||
use ExUnit.Case
|
||||
|
||||
test "muex is a dev/test-only, non-runtime dependency" do
|
||||
deps = Mix.Project.config()[:deps]
|
||||
|
||||
assert {:muex, requirement, opts} = dep!(deps, :muex)
|
||||
assert requirement =~ "0.12"
|
||||
assert opts[:only] == [:dev, :test]
|
||||
assert opts[:runtime] == false
|
||||
end
|
||||
|
||||
test "mutate and mutate.fast aliases are defined" do
|
||||
aliases = Mix.Project.config()[:aliases]
|
||||
|
||||
assert is_function(aliases[:mutate], 1)
|
||||
assert is_function(aliases[:"mutate.fast"], 1)
|
||||
end
|
||||
|
||||
test "an empty app scope fails loudly instead of running over nothing" do
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Firehose.MixProject.mutation_args("mutation_scope.txt", [])
|
||||
end
|
||||
end
|
||||
|
||||
test "ad-hoc args do not bypass the empty-scope failure" do
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Firehose.MixProject.mutation_args("mutation_scope.txt", [
|
||||
"--files",
|
||||
"lib/firehose/accounts/user.ex"
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
defp dep!(deps, name) do
|
||||
case Enum.find(deps, fn
|
||||
{^name, _requirement} -> true
|
||||
{^name, _requirement, _opts} -> true
|
||||
_ -> false
|
||||
end) do
|
||||
{^name, requirement} -> {name, requirement, []}
|
||||
{^name, requirement, opts} -> {name, requirement, opts}
|
||||
nil -> flunk("expected #{inspect(name)} in deps/0")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
defmodule Firehose.MutationScopeTest do
|
||||
# The app's candidate pure modules (Firehose.Accounts.User, Scope and
|
||||
# UserToken) are only exercised through Firehose.DataCase: their tests use
|
||||
# the SQL sandbox, fixtures and Repo. Per the yak's Q45 decision, an app
|
||||
# mutation run would therefore need PostgreSQL, so the scope is deliberately
|
||||
# empty and the alias fails loudly rather than silently running over nothing.
|
||||
#
|
||||
# These pins keep that decision visible: if the scope grows, this file must
|
||||
# change with it, and it may never grow into the web layer.
|
||||
use ExUnit.Case
|
||||
|
||||
@root Path.expand("..", __DIR__)
|
||||
|
||||
test "scope files exist" do
|
||||
assert File.exists?(Path.join(@root, "mutation_scope.txt"))
|
||||
assert File.exists?(Path.join(@root, "mutation_scope_fast.txt"))
|
||||
end
|
||||
|
||||
test "app scope is empty because the candidate modules' tests need PostgreSQL" do
|
||||
assert scope("mutation_scope.txt") == []
|
||||
end
|
||||
|
||||
test "fast scope is a subset of full scope" do
|
||||
out_of_scope =
|
||||
MapSet.difference(
|
||||
MapSet.new(scope("mutation_scope_fast.txt")),
|
||||
MapSet.new(scope("mutation_scope.txt"))
|
||||
)
|
||||
|
||||
assert MapSet.size(out_of_scope) == 0,
|
||||
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
|
||||
end
|
||||
|
||||
test "scope never includes web-layer code" do
|
||||
web_entries =
|
||||
Enum.filter(
|
||||
scope("mutation_scope.txt") ++ scope("mutation_scope_fast.txt"),
|
||||
&String.starts_with?(&1, "lib/firehose_web")
|
||||
)
|
||||
|
||||
assert web_entries == [],
|
||||
"the web layer (controllers, LiveViews, components) is out of scope: #{inspect(web_entries)}"
|
||||
end
|
||||
|
||||
defp scope(file) do
|
||||
@root
|
||||
|> Path.join(file)
|
||||
|> File.read!()
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
defmodule FirehoseWeb.MicroprintsCase do
|
||||
@moduledoc """
|
||||
Helpers for driving the `/microprints` LiveView.
|
||||
|
||||
Both `FirehoseWeb.MicroprintsLiveTest` and `FirehoseWeb.SourceViewerTest` walk
|
||||
the same page through the same two controls (the per-card Expand/Collapse
|
||||
button and the microprint rects), so the mechanics live here instead of being
|
||||
re-implemented in each test module.
|
||||
|
||||
A plain module, imported by the tests, rather than a `use`-able case template:
|
||||
the LiveView tests own their own setup and only share these three actions.
|
||||
"""
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
@doc """
|
||||
Clicks the Expand control of the card for `path` and returns the render.
|
||||
"""
|
||||
def expand_file(view, path) do
|
||||
view
|
||||
|> element(~s(button[phx-value-path="#{path}"]), "Expand")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clicks the Collapse control of the card for `path` and returns the render.
|
||||
"""
|
||||
def collapse_file(view, path) do
|
||||
view
|
||||
|> element(~s(button[phx-value-path="#{path}"]), "Collapse")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clicks a single line of a microprint, highlighting it in the source viewer.
|
||||
|
||||
The microprint renders several rect segments per line, so `element/3` cannot
|
||||
pick one; the `highlight_line` event is dispatched directly instead.
|
||||
"""
|
||||
def click_microprint_rect(view, path, line) do
|
||||
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,238 @@
|
||||
# This file contains the configuration for Credo and you are probably reading
|
||||
# this after creating it with `mix credo.gen.config`.
|
||||
#
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
# The gate for this library is `mix credo --strict` on the command line (the
|
||||
# `credo` target in Makefile). The command line, not this file, decides:
|
||||
# `--strict` overrides the inert `strict: false` key below. Editing `strict`
|
||||
# here to silence a finding would change nothing - fix the finding, or write
|
||||
# down why the check is wrong for this codebase (see `disabled:`).
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
configs: [
|
||||
%{
|
||||
#
|
||||
# Run any config using `mix credo -C <name>`. If no config name is given
|
||||
# "default" is used.
|
||||
#
|
||||
name: "default",
|
||||
#
|
||||
# These are the files included in the analysis:
|
||||
files: %{
|
||||
#
|
||||
# You can give explicit globs or simply directories.
|
||||
# In the latter case `**/*.{ex,exs}` will be used.
|
||||
#
|
||||
included: [
|
||||
"lib/",
|
||||
"src/",
|
||||
"test/",
|
||||
"web/",
|
||||
"apps/*/lib/",
|
||||
"apps/*/src/",
|
||||
"apps/*/test/",
|
||||
"apps/*/web/"
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
|
||||
},
|
||||
#
|
||||
# Load and configure plugins here:
|
||||
#
|
||||
plugins: [],
|
||||
#
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
requires: [],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
# experience, you can change `strict` to `true` below:
|
||||
#
|
||||
strict: false,
|
||||
#
|
||||
# To modify the timeout for parsing files, change this value:
|
||||
#
|
||||
parse_timeout: 5000,
|
||||
#
|
||||
# If you want to use uncolored output by default, you can change `color`
|
||||
# to `false` below:
|
||||
#
|
||||
color: true,
|
||||
#
|
||||
# You can customize the parameters of any check by adding a second element
|
||||
# to the tuple.
|
||||
#
|
||||
# To disable a check put `false` as second element:
|
||||
#
|
||||
# {Credo.Check.Design.DuplicatedCode, false}
|
||||
#
|
||||
checks: %{
|
||||
enabled: [
|
||||
#
|
||||
## Consistency Checks
|
||||
#
|
||||
{Credo.Check.Consistency.ExceptionNames, []},
|
||||
{Credo.Check.Consistency.LineEndings, []},
|
||||
{Credo.Check.Consistency.ParameterPatternMatching, []},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators, []},
|
||||
{Credo.Check.Consistency.SpaceInParentheses, []},
|
||||
{Credo.Check.Consistency.TabsOrSpaces, []},
|
||||
|
||||
#
|
||||
## Design Checks
|
||||
#
|
||||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
{Credo.Check.Design.AliasUsage,
|
||||
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
#
|
||||
{Credo.Check.Readability.AliasOrder, []},
|
||||
{Credo.Check.Readability.FunctionNames, []},
|
||||
{Credo.Check.Readability.LargeNumbers, []},
|
||||
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
|
||||
{Credo.Check.Readability.ModuleAttributeNames, []},
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
{Credo.Check.Readability.ModuleNames, []},
|
||||
{Credo.Check.Readability.ParenthesesInCondition, []},
|
||||
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
|
||||
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
|
||||
{Credo.Check.Readability.PredicateFunctionNames, []},
|
||||
{Credo.Check.Readability.PreferImplicitTry, []},
|
||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
|
||||
{Credo.Check.Readability.VariableNames, []},
|
||||
{Credo.Check.Readability.WithSingleClause, []},
|
||||
|
||||
#
|
||||
## Refactoring Opportunities
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, []},
|
||||
{Credo.Check.Refactor.MapJoin, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, []},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
{Credo.Check.Refactor.WithClauses, []},
|
||||
|
||||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
{Credo.Check.Warning.IExPry, []},
|
||||
{Credo.Check.Warning.IoInspect, []},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
|
||||
{Credo.Check.Warning.OperationOnSameValues, []},
|
||||
{Credo.Check.Warning.OperationWithConstantResult, []},
|
||||
{Credo.Check.Warning.RaiseInsideRescue, []},
|
||||
{Credo.Check.Warning.SpecWithStruct, []},
|
||||
{Credo.Check.Warning.StructFieldAmount, []},
|
||||
{Credo.Check.Warning.UnsafeExec, []},
|
||||
{Credo.Check.Warning.UnusedEnumOperation, []},
|
||||
{Credo.Check.Warning.UnusedFileOperation, []},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation, []},
|
||||
{Credo.Check.Warning.UnusedListOperation, []},
|
||||
{Credo.Check.Warning.UnusedMapOperation, []},
|
||||
{Credo.Check.Warning.UnusedPathOperation, []},
|
||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFilename, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
|
||||
# threshold 30, wired into the Lens pass in Makefile) is this
|
||||
# library's complexity sensor, and it scores complexity against real
|
||||
# coverage: a wide `case` that the tests exercise is not a problem -
|
||||
# that was the itch behind ExCrap. A second, coverage-blind complexity
|
||||
# sensor would just argue with the first, so complexity questions are
|
||||
# settled by the CRAP target. Re-enabling this check without retiring
|
||||
# the CRAP target is a policy change, not a cleanup.
|
||||
#
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||
#
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, []},
|
||||
{Credo.Check.Design.DuplicatedCode, []},
|
||||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||
{Credo.Check.Readability.AliasAs, []},
|
||||
{Credo.Check.Readability.BlockPipe, []},
|
||||
{Credo.Check.Readability.ImplTrue, []},
|
||||
{Credo.Check.Readability.MultiAlias, []},
|
||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||
{Credo.Check.Readability.OneArityFunctionInPipe, []},
|
||||
{Credo.Check.Readability.OnePipePerLine, []},
|
||||
{Credo.Check.Readability.SeparateAliasRequire, []},
|
||||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||
{Credo.Check.Readability.SinglePipe, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||
{Credo.Check.Refactor.ABCSize, []},
|
||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||
{Credo.Check.Refactor.CondInsteadOfIfElse, []},
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.IoPuts, []},
|
||||
{Credo.Check.Refactor.MapMap, []},
|
||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||
{Credo.Check.Refactor.PipeChainStart, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.VariableRebinding, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
# {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]}
|
||||
|
||||
# {Credo.Check.Refactor.MapInto, []},
|
||||
|
||||
#
|
||||
# Custom checks can be created using `mix credo.gen.check`.
|
||||
#
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/_build/
|
||||
/deps/
|
||||
/doc/
|
||||
/cover/
|
||||
*.ez
|
||||
*.beam
|
||||
.elixir_ls/
|
||||
|
||||
+90
-1
@@ -3,13 +3,102 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: deps test format
|
||||
.PHONY: check precommit deps compile test test.coverage coverage crap format format.check verify
|
||||
|
||||
# Cheap static gate for this library (no database, no coverage). Runs from the
|
||||
# root `make check`, which loops app then blogex. `format.check` only reports:
|
||||
# a gate that rewrites the files it judges can never fail. Fix formatting with
|
||||
# `make format`.
|
||||
#
|
||||
# Sensor slot: add `credo` once the "credo strict, tuned config (both apps)"
|
||||
# yak lands blogex's credo dep + .credo.exs. Do not list it before then — a
|
||||
# gate that claims a check it did not run is worse than no gate.
|
||||
check: compile format.check
|
||||
|
||||
# Per-app precommit loop: same target name and alias shape as app/, so both
|
||||
# apps share one habit (compile --warning-as-errors, format, test).
|
||||
precommit:
|
||||
$(MISE_EXEC) mix precommit
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
$(MISE_EXEC) mix deps.get
|
||||
|
||||
# Compile the project (warnings are fatal)
|
||||
compile:
|
||||
$(MISE_EXEC) mix compile --warnings-as-errors
|
||||
|
||||
# Run the test suite
|
||||
test: deps
|
||||
$(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
|
||||
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 functions
|
||||
# Blogex.LinkValidator.validate_link/1 and validate_links/1 at 6.00
|
||||
# ("Summary: files=12 functions=61 scored=61 worst_score=6.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 blogex format`).
|
||||
format.check:
|
||||
$(MISE_EXEC) mix format --check-formatted
|
||||
|
||||
# =============================================================================
|
||||
# Lens pass (heavy checks; root `make verify` runs this before every push)
|
||||
# =============================================================================
|
||||
#
|
||||
# Sensor slots, cheapest first. 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.
|
||||
# - 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)
|
||||
#
|
||||
# The suite runs FIRST: it is what produces the coverage export that the
|
||||
# sensors below read. (Sampling and reporting are separate steps because
|
||||
# `mix test --cover --export-coverage` exports instead of printing a summary,
|
||||
# and reporting through `mix test.coverage` keeps it to one suite run.) Sensors
|
||||
# still run cheapest-first among themselves.
|
||||
VERIFY_SENSORS := coverage crap
|
||||
|
||||
verify:
|
||||
@echo "==> verify(blogex): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]"
|
||||
@$(MAKE) test.coverage
|
||||
@for sensor in $(VERIFY_SENSORS); do echo "==> verify(blogex): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done
|
||||
|
||||
@@ -9,38 +9,60 @@ defmodule Blogex.ExternalLinks do
|
||||
* `rel="noopener"` — the new tab cannot access `window.opener`
|
||||
* `class="blogex-external"` — hook for CSS to draw an external-link
|
||||
marker icon (the host app's stylesheet owns the actual styling)
|
||||
* a `<span class="blogex-external-hint"> (opens in a new tab)</span>`
|
||||
at the end of the link text — announced by screen readers, hidden
|
||||
visually by the host app's stylesheet
|
||||
|
||||
Internal links (relative paths), `mailto:` and `#anchor` links are left
|
||||
untouched.
|
||||
|
||||
The rewrite is idempotent: applying it to already-rewritten HTML does
|
||||
not duplicate attributes.
|
||||
not duplicate attributes or hints.
|
||||
|
||||
Assumptions: the HTML comes from NimblePublisher's Earmark conversion,
|
||||
which emits double-quoted, lowercase attribute names and never adds
|
||||
`target`, `rel` or `class` to anchors. Attribute values are matched
|
||||
with case-insensitive regexes to stay robust for other producers.
|
||||
Anchors are matched together with their closing tag (the hint goes
|
||||
inside), so a stray unclosed `<a>` is left alone.
|
||||
"""
|
||||
|
||||
@marker_class "blogex-external"
|
||||
@hint_class "blogex-external-hint"
|
||||
@hint_text " (opens in a new tab)"
|
||||
@external_href ~r|^https?://|i
|
||||
@anchor ~r|(<a\s[^>]*>)(.*?)(</a>)|is
|
||||
|
||||
@doc """
|
||||
Rewrites all external anchors in an HTML fragment.
|
||||
"""
|
||||
@spec rewrite(String.t()) :: String.t()
|
||||
def rewrite(html) when is_binary(html) do
|
||||
Regex.replace(~r/<a\s[^>]*>/i, html, &decorate_anchor/1)
|
||||
Regex.replace(@anchor, html, fn _anchor, opening, content, closing ->
|
||||
{opening, content} = decorate_anchor(opening, content)
|
||||
opening <> content <> closing
|
||||
end)
|
||||
end
|
||||
|
||||
defp decorate_anchor(anchor) do
|
||||
if external?(href(anchor)) do
|
||||
anchor
|
||||
defp decorate_anchor(opening, content) do
|
||||
if external?(href(opening)) do
|
||||
opening =
|
||||
opening
|
||||
|> ensure_attribute("target", "_blank")
|
||||
|> ensure_rel("noopener")
|
||||
|> ensure_class(@marker_class)
|
||||
|
||||
{opening, ensure_hint(content)}
|
||||
else
|
||||
anchor
|
||||
{opening, content}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_hint(content) do
|
||||
if String.contains?(content, @hint_class) do
|
||||
content
|
||||
else
|
||||
content <> ~s|<span class="#{@hint_class}">#{@hint_text}</span>|
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -38,11 +38,9 @@ defmodule Blogex.LinkError do
|
||||
end
|
||||
|
||||
errors_list =
|
||||
errors
|
||||
|> Enum.map(fn {line, link, reason, _meta} ->
|
||||
Enum.map_join(errors, "\n", fn {line, link, reason, _meta} ->
|
||||
" line #{line}: #{link} — #{reason}"
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
"""
|
||||
invalid internal blog links in #{blog}#{post_label}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
defmodule Blogex.LinkValidator do
|
||||
@moduledoc """
|
||||
Validates internal blog link semantics in markdown post bodies.
|
||||
Validates internal blog link semantics in compiled post bodies.
|
||||
|
||||
Post bodies reach this module as HTML: `Blogex.Blog` validates them in
|
||||
`@before_compile`, after NimblePublisher has rendered the markdown. Markdown
|
||||
link syntax is still understood so bodies and snippets can be validated
|
||||
before rendering, but the HTML `<a href="…">` anchors are the ones that
|
||||
matter in the real pipeline.
|
||||
|
||||
Checks that internal links follow the pattern `/blog/{blog_id}/{slug}`
|
||||
where `blog_id` is a valid blog identifier and `slug` matches the
|
||||
@@ -50,21 +56,25 @@ defmodule Blogex.LinkValidator do
|
||||
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
@doc """
|
||||
Extracts internal blog links from a markdown body.
|
||||
Extracts internal blog links from a post body.
|
||||
|
||||
Returns a list of link paths (strings) that match the pattern
|
||||
`/blog/{engineering|releases}/{slug}`. External links and non-blog
|
||||
internal links are ignored.
|
||||
Returns a list of link paths (strings) that look like `/blog/…` links.
|
||||
External links and non-blog internal links are ignored. Blog IDs and slugs
|
||||
are *not* judged here — `validate_link/1` classifies those.
|
||||
|
||||
Handles both markdown link syntax `[text](url)` and HTML `<a href="url">`.
|
||||
(Post bodies are HTML, rendered by NimblePublisher at compile time.)
|
||||
HTML anchors are read with Floki, so attribute order, quoting and extra
|
||||
attributes do not matter. Markdown link syntax `[text](url)` is extracted
|
||||
too (raw markdown bodies and snippets), and the two scans are merged.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> extract_links("[link](/blog/engineering/post)")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("<p><a href=\"/blog/engineering/post\">link</a></p>")
|
||||
iex> extract_links("<p><a href=\\"/blog/engineering/post\\">link</a></p>")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("<p><a class=\\"x\\" href='/blog/engineering/post'>link</a></p>")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("See [GitHub](https://github.com)")
|
||||
@@ -72,19 +82,22 @@ defmodule Blogex.LinkValidator do
|
||||
"""
|
||||
@spec extract_links(String.t()) :: [String.t()]
|
||||
def extract_links(body) when is_binary(body) do
|
||||
markdown_links =
|
||||
(markdown_links(body) ++ html_links(body))
|
||||
|> Enum.uniq()
|
||||
|> Enum.filter(&internal_blog_link?/1)
|
||||
end
|
||||
|
||||
defp markdown_links(body) do
|
||||
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, _, path] -> path end)
|
||||
end
|
||||
|
||||
html_links =
|
||||
~r/<a\s+href=["']([^"']*)["']/i
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, path] -> path end)
|
||||
|
||||
(markdown_links ++ html_links)
|
||||
|> Enum.uniq()
|
||||
|> Enum.filter(&internal_blog_link?/1)
|
||||
defp html_links(body) do
|
||||
body
|
||||
|> Floki.parse_document!()
|
||||
|> Floki.find("a[href]")
|
||||
|> Enum.flat_map(&Floki.attribute(&1, "href"))
|
||||
end
|
||||
|
||||
defp internal_blog_link?(path) do
|
||||
@@ -116,22 +129,24 @@ defmodule Blogex.LinkValidator do
|
||||
@spec validate_link(String.t()) :: :ok | {:error, String.t()}
|
||||
def validate_link(link) when is_binary(link) do
|
||||
case parse_blog_link(link) do
|
||||
nil ->
|
||||
{:error, "not a blog link: #{link}"}
|
||||
|
||||
{blog_id_str, slug_part} ->
|
||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
||||
{:ok, _blog_atom} ->
|
||||
if String.starts_with?(slug_part, "tag/") do
|
||||
validate_tag(String.replace_prefix(slug_part, "tag/", ""))
|
||||
else
|
||||
validate_slug(slug_part)
|
||||
nil -> {:error, "not a blog link: #{link}"}
|
||||
{blog_id_str, path} -> validate_blog_path(blog_id_str, path)
|
||||
end
|
||||
end
|
||||
|
||||
# The blog ID must be one this app knows about; only then is the rest of the
|
||||
# path worth looking at. Extracted so `validate_link/1` stays one `case` deep:
|
||||
# the branching here is data (which part of the path is this?), not control.
|
||||
@spec validate_blog_path(String.t(), String.t()) :: :ok | {:error, String.t()}
|
||||
defp validate_blog_path(blog_id_str, path) do
|
||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
||||
{:ok, _blog_atom} -> validate_path(path)
|
||||
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_path("tag/" <> tag), do: validate_tag(tag)
|
||||
defp validate_path(slug), do: validate_slug(slug)
|
||||
|
||||
@doc """
|
||||
Validates a list of links, returning errors with line numbers.
|
||||
@@ -207,21 +222,23 @@ defmodule Blogex.LinkValidator do
|
||||
@doc false
|
||||
@spec parse_blog_link(String.t()) :: {String.t(), String.t()} | nil
|
||||
def parse_blog_link(path) do
|
||||
with ["", "blog", blog_id, rest] <- String.split(path, "/", parts: 4) do
|
||||
case String.split(path, "/", parts: 4) do
|
||||
["", "blog", blog_id, rest] ->
|
||||
slug = String.slice(rest, 0, slug_end(rest))
|
||||
{blog_id, slug}
|
||||
else
|
||||
_ -> nil
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec slug_end(String.t()) :: integer()
|
||||
defp slug_end(str) do
|
||||
case String.split(str, ["?", "#"], parts: 2) do
|
||||
[slug | _] -> String.length(slug)
|
||||
_ -> String.length(str)
|
||||
end
|
||||
# `String.split/3` always returns a non-empty list whose head is a binary,
|
||||
# so there is no empty/other case to handle here.
|
||||
[slug | _] = String.split(str, ["?", "#"], parts: 2)
|
||||
String.length(slug)
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -34,12 +34,15 @@ defmodule Blogex.Router do
|
||||
|
||||
use Plug.Router
|
||||
|
||||
alias Blogex.Router.Helpers
|
||||
alias Phoenix.HTML.Safe
|
||||
|
||||
plug :match
|
||||
plug :dispatch
|
||||
|
||||
get "/feed.xml" do
|
||||
blog = conn.private[:blogex_blog]
|
||||
base_url = Blogex.Router.Helpers.base_url(conn)
|
||||
base_url = Helpers.base_url(conn)
|
||||
|
||||
xml = Blogex.Feed.rss(blog, base_url)
|
||||
|
||||
@@ -50,7 +53,7 @@ defmodule Blogex.Router do
|
||||
|
||||
get "/atom.xml" do
|
||||
blog = conn.private[:blogex_blog]
|
||||
base_url = Blogex.Router.Helpers.base_url(conn)
|
||||
base_url = Helpers.base_url(conn)
|
||||
|
||||
xml = Blogex.Feed.atom(blog, base_url)
|
||||
|
||||
@@ -164,7 +167,7 @@ defmodule Blogex.Router do
|
||||
defp send_html(conn, rendered) do
|
||||
html =
|
||||
rendered
|
||||
|> Phoenix.HTML.Safe.to_iodata()
|
||||
|> Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
conn
|
||||
|
||||
+116
-1
@@ -11,7 +11,9 @@ defmodule Blogex.MixProject do
|
||||
elixir: "~> 1.15",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
test_coverage: test_coverage(),
|
||||
docs: docs(),
|
||||
package: package(),
|
||||
description: "A multi-blog engine powered by NimblePublisher for Phoenix apps"
|
||||
@@ -27,9 +29,16 @@ defmodule Blogex.MixProject do
|
||||
]
|
||||
end
|
||||
|
||||
def cli do
|
||||
[
|
||||
preferred_envs: [precommit: :test]
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:nimble_publisher, "~> 2.0"},
|
||||
{:floki, "~> 0.38"},
|
||||
{:makeup_elixir, ">= 0.0.0"},
|
||||
{:makeup_erlang, ">= 0.0.0"},
|
||||
{:phoenix, "~> 1.7"},
|
||||
@@ -37,8 +46,43 @@ defmodule Blogex.MixProject do
|
||||
{:phoenix_live_view, "~> 1.0"},
|
||||
{:jason, "~> 1.4"},
|
||||
{:plug, "~> 1.15"},
|
||||
# Declared directly although unused today (no boundaries, no compiler
|
||||
# entry): `ex_crap` supplies boundary transitively, and a compile-time
|
||||
# tool must not arrive or vanish as a side effect of a metric tool.
|
||||
{:boundary, "~> 0.11", runtime: false},
|
||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
||||
{:rustler, "~> 0.38.0", optional: true}
|
||||
{:rustler, "~> 0.38.0", optional: true},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
|
||||
{:muex, "~> 0.12", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
|
||||
# in blogex/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
|
||||
# land; never lower it. A drop means the change (or an exclusion) is wrong.
|
||||
#
|
||||
# Measured 2026-10-29, with the test scaffolding below excluded: 100.00% —
|
||||
# the same via `mix test --cover` and via `mix test --cover --export-coverage
|
||||
# default` + `mix test.coverage`. Floor is the integer below that.
|
||||
#
|
||||
# Excluded: the test scaffolding in `ignore_modules` below. The two registry
|
||||
# blog doubles (Blogex.Test.AlphaBlog/BetaBlog) are deliberately not listed
|
||||
# there: they measure 100.00% too, so counting them costs nothing.
|
||||
#
|
||||
# Everything in lib/blogex counts, and nothing is uncovered: Blogex.LinkError
|
||||
# was the last gap at 88.89% (8 of 9 lines) until the credo yak's tests closed
|
||||
# it. The rendered-HTML behavior tests took Blogex.Components and Blogex.Layout
|
||||
# from 0% to 100%, and Blogex.Router covers its browser arms and `match _`.
|
||||
# A floor of 100 means every new line needs a test before `make verify` passes.
|
||||
defp test_coverage do
|
||||
[
|
||||
summary: [threshold: 100],
|
||||
ignore_modules: [
|
||||
Blogex.Test.FakeBlog,
|
||||
Blogex.Test.PostBuilder,
|
||||
Blogex.Test.Setup
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -50,6 +94,77 @@ defmodule Blogex.MixProject do
|
||||
]
|
||||
end
|
||||
|
||||
# Same `precommit` alias name and shape as app/mix.exs, so both apps share
|
||||
# one loop: compile (warnings fatal), format, test. Heavy sensors belong in
|
||||
# the Lens pass (`make verify`), not here.
|
||||
#
|
||||
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
|
||||
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
|
||||
# survivor is reported rather than fatal. The scope lives in the plain-text
|
||||
# files beside this one; the aliases read them at run time, so the text file
|
||||
# stays the single source of truth.
|
||||
defp aliases do
|
||||
[
|
||||
precommit: ["compile --warning-as-errors", "format", "test"],
|
||||
mutate: &mutate/1,
|
||||
"mutate.fast": &mutate_fast/1
|
||||
]
|
||||
end
|
||||
|
||||
# No macro/`use` mutators: the scoped modules are plain functions, and the
|
||||
# macro-heavy modules (blog.ex, router.ex, components.ex, layout.ex) are out
|
||||
# of scope entirely. Spellings are muex 0.12's CLI names.
|
||||
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
|
||||
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
|
||||
|
||||
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
|
||||
|
||||
defp run_mutation(scope_file, args) do
|
||||
Mix.Task.run("muex", mutation_args(scope_file, args))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def mutation_args(scope_file, extra_args \\ []) do
|
||||
files = read_mutation_scope(scope_file)
|
||||
|
||||
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
|
||||
# into a gate. Extra args come before our defaults, and OptionParser keeps
|
||||
# the first value, so `mix mutate --files lib/blogex/seo.ex` targets one
|
||||
# file while `mix mutate` covers the whole scope file.
|
||||
["--fail-at", "0"] ++
|
||||
extra_args ++
|
||||
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
|
||||
end
|
||||
|
||||
@doc false
|
||||
def read_mutation_scope(scope_file) do
|
||||
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
|
||||
|
||||
files =
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
contents
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
|
||||
"refusing to run the mutation sensor over an unknown scope"
|
||||
)
|
||||
end
|
||||
|
||||
if files == [] do
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
|
||||
)
|
||||
end
|
||||
|
||||
files
|
||||
end
|
||||
|
||||
defp package do
|
||||
[
|
||||
licenses: ["MIT"],
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
%{
|
||||
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
|
||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
|
||||
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
|
||||
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
lib/blogex/link_validator.ex
|
||||
lib/blogex/seo.ex
|
||||
lib/blogex/feed.ex
|
||||
lib/blogex/post.ex
|
||||
lib/blogex/external_links.ex
|
||||
lib/blogex/registry.ex
|
||||
lib/blogex/not_found_error.ex
|
||||
lib/blogex/link_error.ex
|
||||
@@ -0,0 +1,3 @@
|
||||
lib/blogex/link_validator.ex
|
||||
lib/blogex/seo.ex
|
||||
lib/blogex/post.ex
|
||||
@@ -0,0 +1,8 @@
|
||||
%{
|
||||
title: "Draft Post (test fixture)",
|
||||
author: "Test Author",
|
||||
description: "A draft used by the show_drafts? tests",
|
||||
published: false
|
||||
}
|
||||
---
|
||||
This post is a draft and is hidden unless :show_drafts is on.
|
||||
@@ -0,0 +1,7 @@
|
||||
%{
|
||||
title: "Internal Link Post",
|
||||
author: "Test Author",
|
||||
description: "A post that links to another post"
|
||||
}
|
||||
---
|
||||
This post links to [hello world](/blog/engineering/hello-world) in its body.
|
||||
@@ -93,4 +93,75 @@ defmodule Blogex.BlogIntegrationTest do
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validation sees compiled post bodies" do
|
||||
# `_validate_links/2` runs on `post.body` *after* NimblePublisher rendered
|
||||
# the markdown, so validation only works if extraction reads the compiled
|
||||
# HTML. This pins that contract against a real compiled body: if
|
||||
# extraction ever goes back to scanning markdown syntax only, it fails.
|
||||
test "extract_links finds internal links in a compiled post body" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_compiled_body.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogCompiledBody do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-03-internal-link-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogCompiledBody, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
|
||||
# `apply/3`: the module is compiled at runtime, so a static call would
|
||||
# warn ("module is not available") on every test compile.
|
||||
[post] = apply(TestBlogCompiledBody, :all_posts, [])
|
||||
|
||||
# The markdown source is `[hello world](/blog/engineering/hello-world)`.
|
||||
assert post.body =~ "/blog/engineering/hello-world"
|
||||
|
||||
assert "/blog/engineering/hello-world" in Blogex.LinkValidator.extract_links(post.body)
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_drafts?/0" do
|
||||
# The only path through `Blogex.show_drafts?/0` (blogex.ex): `use Blogex.Blog`
|
||||
# compiles a real module over a real draft fixture, so the flag is read by
|
||||
# the generated `all_posts/0` rather than asserted in isolation.
|
||||
#
|
||||
# One test, one compile: compiling the same module name twice would print a
|
||||
# "redefining module" warning, and warnings are fatal everywhere in this repo.
|
||||
test "drafts are hidden by default and visible when :show_drafts is on" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_show_drafts.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogShowDrafts do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-03-draft-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogShowDrafts, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
|
||||
on_exit(fn ->
|
||||
File.rm!(tmp_file)
|
||||
Application.delete_env(:blogex, :show_drafts)
|
||||
end)
|
||||
|
||||
assert apply(TestBlogShowDrafts, :all_posts, []) == []
|
||||
|
||||
Application.put_env(:blogex, :show_drafts, true)
|
||||
|
||||
assert [post] = apply(TestBlogShowDrafts, :all_posts, [])
|
||||
assert post.title == "Draft Post (test fixture)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Blogex.BoundaryDependencyTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mix.Dep.Lock
|
||||
|
||||
# blogex declares no boundaries and does not run the boundary compiler, so
|
||||
# this dependency is inert today. It is declared directly anyway (app/mix.exs
|
||||
# does the same) so the compiler cannot arrive or vanish as a side effect of
|
||||
# `ex_crap`, which supplies it transitively. This pins "direct, and locked to a
|
||||
# version that satisfies the requirement"; if boundary is ever wired up here,
|
||||
# the compile gate is that change's pin, not this test.
|
||||
describe "boundary dependency" do
|
||||
test "boundary is a direct dependency, not one inherited from ex_crap" do
|
||||
assert {:boundary, requirement, opts} =
|
||||
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
|
||||
|
||||
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
|
||||
assert Version.match?(locked_version(:boundary), requirement)
|
||||
end
|
||||
end
|
||||
|
||||
defp locked_version(app) do
|
||||
Lock.read()
|
||||
|> Map.fetch!(app)
|
||||
|> elem(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
defmodule Blogex.ComponentsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
@base_path "/blog/engineering"
|
||||
|
||||
describe "post_index/1" do
|
||||
test "links each post title to its own page under base_path" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(id: "first-post", title: "First post"),
|
||||
build(id: "second-post", title: "Second post")
|
||||
])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/first-post")
|
||||
assert html =~ ">First post</a>"
|
||||
assert html =~ ~s(href="#{@base_path}/second-post")
|
||||
assert html =~ ">Second post</a>"
|
||||
end
|
||||
|
||||
test "shows each post's description" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(id: "processes-post", description: "Everything about processes"),
|
||||
build(id: "supervision-post", description: "Everything about supervision")
|
||||
])
|
||||
|
||||
assert html =~ "Everything about processes"
|
||||
assert html =~ "Everything about supervision"
|
||||
end
|
||||
|
||||
test "shows date, author and tags for each post" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(
|
||||
id: "authored-post",
|
||||
date: ~D[2026-03-10],
|
||||
author: "Ada Lovelace",
|
||||
tags: ["elixir"]
|
||||
)
|
||||
])
|
||||
|
||||
assert html =~ "March 10, 2026"
|
||||
assert html =~ "by Ada Lovelace"
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
end
|
||||
end
|
||||
|
||||
describe "post_show/1" do
|
||||
test "renders the post title as the page heading" do
|
||||
html = render_post_show(build(id: "heading-post", title: "Rendering Rendered HTML"))
|
||||
|
||||
assert html =~ "<h1>Rendering Rendered HTML</h1>"
|
||||
end
|
||||
|
||||
test "renders the body as markup, not escaped text" do
|
||||
html =
|
||||
render_post_show(build(id: "markup-post", body: "<p>Hello <strong>world</strong></p>"))
|
||||
|
||||
assert html =~ "<p>Hello <strong>world</strong></p>"
|
||||
refute html =~ "<p>"
|
||||
refute html =~ "<strong>"
|
||||
end
|
||||
|
||||
test "shows the post's date, author and tags" do
|
||||
html =
|
||||
render_post_show(
|
||||
build(
|
||||
id: "meta-post",
|
||||
date: ~D[2026-03-10],
|
||||
author: "Grace Hopper",
|
||||
tags: ["compilers", "navy"]
|
||||
)
|
||||
)
|
||||
|
||||
assert html =~ ~s(<time datetime="2026-03-10">)
|
||||
assert html =~ "March 10, 2026"
|
||||
assert html =~ "by Grace Hopper"
|
||||
assert html =~ ~s(href="#{@base_path}/tag/compilers")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/navy")
|
||||
end
|
||||
end
|
||||
|
||||
describe "post_meta/1" do
|
||||
test "renders the date as a machine-readable time element and a human date" do
|
||||
html = render_post_meta(build(date: ~D[2026-03-10]))
|
||||
|
||||
assert html =~ ~s(<time datetime="2026-03-10">)
|
||||
assert html =~ "March 10, 2026"
|
||||
end
|
||||
|
||||
test "shows the author when the post has one" do
|
||||
html = render_post_meta(build(author: "Ada Lovelace"))
|
||||
|
||||
assert html =~ ~s(<span class="blogex-post-author">)
|
||||
assert html =~ "by Ada Lovelace"
|
||||
end
|
||||
|
||||
test "has no author line when the post has no author" do
|
||||
html = render_post_meta(build(author: nil))
|
||||
|
||||
refute html =~ "blogex-post-author"
|
||||
refute html =~ "by "
|
||||
end
|
||||
|
||||
test "links every tag under base_path" do
|
||||
html = render_post_meta(build(tags: ["elixir", "otp"]))
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/otp")
|
||||
end
|
||||
|
||||
test "marks only the current tag as active" do
|
||||
html = render_post_meta(build(tags: ["elixir", "otp"]), current_tag: "elixir")
|
||||
|
||||
assert occurrences(html, "blogex-tag-active") == 1
|
||||
|
||||
assert [_, after_active] =
|
||||
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
|
||||
|
||||
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
|
||||
end
|
||||
end
|
||||
|
||||
describe "tag_list/1" do
|
||||
test "links every tag under base_path" do
|
||||
html = render_tag_list(["elixir", "otp"])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/otp")
|
||||
end
|
||||
|
||||
test "marks only the current tag as active" do
|
||||
html = render_tag_list(["elixir", "otp"], current_tag: "elixir")
|
||||
|
||||
assert occurrences(html, "blogex-tag-active") == 1
|
||||
|
||||
assert [_, after_active] =
|
||||
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
|
||||
|
||||
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
|
||||
end
|
||||
end
|
||||
|
||||
describe "pagination/1" do
|
||||
test "renders nothing when there is only one page" do
|
||||
assert render_pagination(page: 1, total_pages: 1) |> String.trim() == ""
|
||||
end
|
||||
|
||||
test "on the first of several pages there is an Older link and no Newer link" do
|
||||
html = render_pagination(page: 1, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 1 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=2")
|
||||
assert html =~ "Older"
|
||||
refute html =~ "Newer"
|
||||
end
|
||||
|
||||
test "on a middle page there are both Newer and Older links" do
|
||||
html = render_pagination(page: 2, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 2 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=1")
|
||||
assert html =~ ~s(href="#{@base_path}?page=3")
|
||||
assert html =~ "Newer"
|
||||
assert html =~ "Older"
|
||||
end
|
||||
|
||||
test "on the last page there is a Newer link and no Older link" do
|
||||
html = render_pagination(page: 3, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 3 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=2")
|
||||
assert html =~ "Newer"
|
||||
refute html =~ "Older"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_post_index(posts, current_tag \\ nil) do
|
||||
render_component(&Blogex.Components.post_index/1,
|
||||
posts: posts,
|
||||
base_path: @base_path,
|
||||
current_tag: current_tag
|
||||
)
|
||||
end
|
||||
|
||||
defp render_post_show(post) do
|
||||
render_component(&Blogex.Components.post_show/1, post: post, base_path: @base_path)
|
||||
end
|
||||
|
||||
defp render_post_meta(post, opts \\ []) do
|
||||
render_component(&Blogex.Components.post_meta/1,
|
||||
post: post,
|
||||
base_path: @base_path,
|
||||
current_tag: opts[:current_tag]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_tag_list(tags, opts \\ []) do
|
||||
render_component(&Blogex.Components.tag_list/1,
|
||||
tags: tags,
|
||||
base_path: @base_path,
|
||||
current_tag: opts[:current_tag]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_pagination(opts) do
|
||||
render_component(&Blogex.Components.pagination/1,
|
||||
page: opts[:page],
|
||||
total_pages: opts[:total_pages],
|
||||
base_path: @base_path
|
||||
)
|
||||
end
|
||||
|
||||
defp occurrences(html, token) do
|
||||
html
|
||||
|> String.split(token)
|
||||
|> length()
|
||||
|> Kernel.-(1)
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do
|
||||
rewritten = ExternalLinks.rewrite(html)
|
||||
|
||||
assert rewritten ==
|
||||
~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>.</p>)
|
||||
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
|
||||
end
|
||||
|
||||
test "also rewrites external http links" do
|
||||
@@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do
|
||||
rewritten = ExternalLinks.rewrite(html)
|
||||
|
||||
assert rewritten ==
|
||||
~s(<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a</a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b</a></p>)
|
||||
~s|<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a<span class="blogex-external-hint"> (opens in a new tab)</span></a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b<span class="blogex-external-hint"> (opens in a new tab)</span></a></p>|
|
||||
end
|
||||
|
||||
test "preserves other attributes on the anchor" do
|
||||
@@ -56,6 +56,48 @@ defmodule Blogex.ExternalLinksTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "rewrite/1 accessible hint" do
|
||||
test "appends a visually hidden hint inside the external anchor" do
|
||||
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a>.</p>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) ==
|
||||
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
|
||||
end
|
||||
|
||||
test "adds one hint per external link" do
|
||||
html = ~s(<a href="https://a.example/">a</a> and <a href="https://b.example/">b</a>)
|
||||
|
||||
assert html |> ExternalLinks.rewrite() |> hints() == 2
|
||||
end
|
||||
|
||||
test "places the hint after inner markup, keeping it untouched" do
|
||||
html = ~s(<a href="https://example.com/"><code>docs</code> and more</a>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) =~
|
||||
~s|<code>docs</code> and more<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
|
||||
end
|
||||
|
||||
test "does not add a second hint when applied twice" do
|
||||
once = ExternalLinks.rewrite(~s(<a href="https://example.com/">once</a>))
|
||||
twice = ExternalLinks.rewrite(once)
|
||||
|
||||
assert twice == once
|
||||
assert hints(twice) == 1
|
||||
end
|
||||
|
||||
test "adds no hint to links that are not external" do
|
||||
for html <- [
|
||||
~s(<a href="/blog/engineering/swim-the-test">internal</a>),
|
||||
~s(<a href="mailto:hi@example.com">mail</a>),
|
||||
~s(<a href="#section">jump</a>)
|
||||
] do
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
end
|
||||
|
||||
defp hints(html), do: length(Regex.scan(~r/class="blogex-external-hint"/, html))
|
||||
end
|
||||
|
||||
describe "rewrite/1 leaves other links untouched" do
|
||||
test "internal relative links are unchanged" do
|
||||
html = ~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
|
||||
@@ -75,6 +117,12 @@ defmodule Blogex.ExternalLinksTest do
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
|
||||
test "anchors without an href are unchanged" do
|
||||
html = ~s(<a name="top">top</a>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
|
||||
test "plain text without anchors is unchanged" do
|
||||
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
defmodule Blogex.LayoutTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
@base_path "/blog/engineering"
|
||||
@blog_title "Engineering Blog"
|
||||
@blog_description "Notes from the platform team"
|
||||
|
||||
describe "page/1" do
|
||||
test "titles the document and wraps the inner content" do
|
||||
html = render_page("A document title")
|
||||
|
||||
assert html =~ "<!DOCTYPE html>"
|
||||
assert html =~ ~s(<html lang="en">)
|
||||
assert html =~ "<title>A document title</title>"
|
||||
end
|
||||
|
||||
test "renders inner_content as markup, not escaped text" do
|
||||
html = render_page("A document title", Phoenix.HTML.raw("<p>inner</p>"))
|
||||
|
||||
assert html =~ "<p>inner</p>"
|
||||
refute html =~ "<p>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "index_page/1" do
|
||||
test "shows the blog title and description" do
|
||||
html = render_index_page([])
|
||||
|
||||
assert html =~ "<h1>#{@blog_title}</h1>"
|
||||
assert html =~ @blog_description
|
||||
end
|
||||
|
||||
test "lists the posts with their titles linked" do
|
||||
html = render_index_page([build(id: "first-post", title: "First post")])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/first-post")
|
||||
assert html =~ ">First post</a>"
|
||||
end
|
||||
|
||||
test "shows no pagination when the blog has a single page" do
|
||||
html = render_index_page([build(id: "only-post")], page: 1, total_pages: 1)
|
||||
|
||||
refute html =~ "Older"
|
||||
refute html =~ "Newer"
|
||||
refute html =~ "Page 1 of 1"
|
||||
end
|
||||
|
||||
test "shows pagination when there is more than one page" do
|
||||
html = render_index_page([build(id: "paged-post")], page: 2, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 2 of 3"
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_page/1" do
|
||||
test "uses the post title as the document title" do
|
||||
post = build(id: "titled-post", title: "A Post About Layout")
|
||||
|
||||
assert render_show_page(post) =~ "<title>#{post.title}</title>"
|
||||
end
|
||||
|
||||
test "offers a link back to the blog index" do
|
||||
html = render_show_page(build())
|
||||
|
||||
assert html =~ ~s(<a href="#{@base_path}">← Back</a>)
|
||||
end
|
||||
|
||||
test "renders the post body as markup, not escaped text" do
|
||||
html = render_show_page(build(body: "<p>Hello <strong>world</strong></p>"))
|
||||
|
||||
assert html =~ "<p>Hello <strong>world</strong></p>"
|
||||
refute html =~ "<p>"
|
||||
refute html =~ "<strong>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "tag_page/1" do
|
||||
test "titles the page with the blog title and the tag" do
|
||||
html = render_tag_page(tag: "elixir", posts: [])
|
||||
|
||||
assert html =~ "<title>#{@blog_title} — #elixir</title>"
|
||||
end
|
||||
|
||||
test "announces the tag and lists the posts it was given" do
|
||||
html =
|
||||
render_tag_page(
|
||||
tag: "elixir",
|
||||
posts: [
|
||||
build(id: "tagged-post", title: "Tagged post"),
|
||||
build(id: "another-tagged-post", title: "Another tagged post")
|
||||
]
|
||||
)
|
||||
|
||||
assert html =~ ~s(<h1>Posts tagged "elixir"</h1>)
|
||||
assert html =~ ~s(href="#{@base_path}/tagged-post")
|
||||
assert html =~ ">Tagged post</a>"
|
||||
assert html =~ ~s(href="#{@base_path}/another-tagged-post")
|
||||
assert html =~ ">Another tagged post</a>"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_page(title, inner_content \\ Phoenix.HTML.raw("<p>inner</p>")) do
|
||||
render_component(&Blogex.Layout.page/1, title: title, inner_content: inner_content)
|
||||
end
|
||||
|
||||
defp render_index_page(posts, opts \\ []) do
|
||||
render_component(&Blogex.Layout.index_page/1,
|
||||
blog_title: @blog_title,
|
||||
blog_description: @blog_description,
|
||||
posts: posts,
|
||||
base_path: @base_path,
|
||||
page: Keyword.get(opts, :page, 1),
|
||||
total_pages: Keyword.get(opts, :total_pages, 1)
|
||||
)
|
||||
end
|
||||
|
||||
defp render_show_page(post) do
|
||||
render_component(&Blogex.Layout.show_page/1, post: post, base_path: @base_path)
|
||||
end
|
||||
|
||||
defp render_tag_page(opts) do
|
||||
render_component(&Blogex.Layout.tag_page/1,
|
||||
blog_title: @blog_title,
|
||||
tag: opts[:tag],
|
||||
posts: opts[:posts],
|
||||
base_path: @base_path
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
defmodule Blogex.LinkErrorTest do
|
||||
@moduledoc """
|
||||
Pins the rendered exception message, not just the struct.
|
||||
|
||||
The message is what a compile-time failure shows a human: the indentation and
|
||||
the newline between errors are load-bearing. This is a characterization test
|
||||
written before `message/1` was rewritten (`Enum.map/2 |> Enum.join/2` ->
|
||||
`Enum.map_join/3`), so it passes on both sides of that change by design - it
|
||||
exists to make the rewrite safe, not to prove new behaviour.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "message/1 renders one indented line per error" do
|
||||
error = %Blogex.LinkError{
|
||||
blog: :engineering,
|
||||
post_id: "my-post",
|
||||
errors: [
|
||||
{1, "/blog/unknown/broken", "unknown blog ID: unknown", [post_id: "my-post"]},
|
||||
{7, "/blog/engineering/", "empty slug", [post_id: "my-post"]}
|
||||
]
|
||||
}
|
||||
|
||||
assert Exception.message(error) == """
|
||||
invalid internal blog links in engineering (post: my-post)
|
||||
|
||||
line 1: /blog/unknown/broken — unknown blog ID: unknown
|
||||
line 7: /blog/engineering/ — empty slug
|
||||
"""
|
||||
end
|
||||
|
||||
test "message/1 omits the post label when there is no post id" do
|
||||
error = %Blogex.LinkError{
|
||||
blog: :engineering,
|
||||
post_id: nil,
|
||||
errors: [{3, "/blog/unknown/broken", "unknown blog ID: unknown", []}]
|
||||
}
|
||||
|
||||
assert Exception.message(error) == """
|
||||
invalid internal blog links in engineering
|
||||
|
||||
line 3: /blog/unknown/broken — unknown blog ID: unknown
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,21 @@ defmodule Blogex.LinkValidatorTest do
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/tag/pi.dev"]
|
||||
end
|
||||
|
||||
# HTML anchors are not "the href right after <a (" - they are whatever the
|
||||
# renderer emits. Pinned with the shapes a hand-rolled scan misses.
|
||||
test "extracts internal blog links whatever the anchor's attributes look like" do
|
||||
body =
|
||||
~s(<p><a class="post-link" href="/blog/engineering/first">first</a>) <>
|
||||
~s( <a href=/blog/releases/second>second</a>) <>
|
||||
~s( <a data-x="1" aria-label="third" href='/blog/engineering/third'>third</a></p>)
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/first",
|
||||
"/blog/releases/second",
|
||||
"/blog/engineering/third"
|
||||
]
|
||||
end
|
||||
|
||||
test "ignores external links" do
|
||||
body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)."
|
||||
|
||||
@@ -157,6 +172,12 @@ defmodule Blogex.LinkValidatorTest do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev#top") == :ok
|
||||
end
|
||||
|
||||
# Tags are user-defined: the documented rule restricts *slugs*, not tags.
|
||||
test "accepts tag links with dots and capitals" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev") == :ok
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/Pi.dev") == :ok
|
||||
end
|
||||
|
||||
test "rejects tag page link with empty tag" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/") ==
|
||||
{:error, "empty tag in tag link"}
|
||||
@@ -224,6 +245,24 @@ defmodule Blogex.LinkValidatorTest do
|
||||
end
|
||||
|
||||
describe "validate_body/2" do
|
||||
# The guard is a conjunction, not a disjunction: both arguments must have
|
||||
# the documented types. Mutating `and` to `or` would let a string blog id
|
||||
# (or a non-binary body) through the guard to crash somewhere else, so pin
|
||||
# the contract at the door.
|
||||
test "rejects a non-atom blog id at the function clause guard" do
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
# apply/3 keeps the compiler's type checker quiet: the point is the
|
||||
# runtime guard, and the test deliberately passes an invalid type.
|
||||
apply(LinkValidator, :validate_body, ["Just text, no links.", "engineering"])
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects a non-binary body at the function clause guard" do
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
apply(LinkValidator, :validate_body, [[], :engineering])
|
||||
end
|
||||
end
|
||||
|
||||
test "returns :ok when body has no internal blog links" do
|
||||
body = "Just text, no links."
|
||||
|
||||
@@ -267,5 +306,61 @@ defmodule Blogex.LinkValidatorTest do
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
# `_validate_links/2` hands this module `post.body`, which NimblePublisher
|
||||
# already rendered to HTML, so failure reporting has to work on HTML bodies.
|
||||
# These are the two reports the compile-time integration tests depend on.
|
||||
test "reports an unknown blog ID in an HTML body" do
|
||||
body = ~s(<p><a href="/blog/unknown/broken">bad</a></p>)
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/broken",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: "html-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "reports an invalid slug in an HTML body" do
|
||||
body = ~s(<p><a href="/blog/engineering/My-Post">bad</a></p>)
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/engineering/My-Post",
|
||||
"slug must be lowercase alphanumeric with hyphens: My-Post",
|
||||
post_id: "html-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_blog_link/1" do
|
||||
# `slug_end/1` decides where the slug stops so `?query` / `#fragment` are
|
||||
# not validated as part of it. Pinned directly on `parse_blog_link/1`
|
||||
# (both are `@doc false`) because `validate_link/1` only reaches them via
|
||||
# "valid slug" cases, and a wrong cut would still validate by luck.
|
||||
test "strips query strings and fragments from the slug" do
|
||||
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world?utm=1") ==
|
||||
{"engineering", "hello-world"}
|
||||
|
||||
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world#top") ==
|
||||
{"engineering", "hello-world"}
|
||||
end
|
||||
|
||||
# Only the paths that cannot be a blog link return nil; a nested slug
|
||||
# ("/blog/eng/a/post") still parses and is rejected later by validate_slug/1.
|
||||
test "returns nil for anything that is not /blog/<blog>/<slug>" do
|
||||
refute LinkValidator.parse_blog_link("/blog/engineering")
|
||||
refute LinkValidator.parse_blog_link("/notblog/engineering/a-post")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ defmodule Blogex.PostTest do
|
||||
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
|
||||
|
||||
assert post.body =~
|
||||
~s(<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>)
|
||||
~s|<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
|
||||
end
|
||||
|
||||
test "leaves internal links in stored body untouched" do
|
||||
|
||||
@@ -2,41 +2,7 @@ defmodule Blogex.RegistryTest do
|
||||
use ExUnit.Case
|
||||
|
||||
alias Blogex.Registry
|
||||
|
||||
defmodule AlphaBlog do
|
||||
def blog_id, do: :alpha
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_tags, do: ["elixir"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
|
||||
Blogex.Test.PostBuilder.build(
|
||||
id: "a-draft",
|
||||
date: ~D[2026-03-05],
|
||||
blog: :alpha,
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
defmodule BetaBlog do
|
||||
def blog_id, do: :beta
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_tags, do: ["devops"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
|
||||
Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
|
||||
]
|
||||
end
|
||||
alias Blogex.Test.{AlphaBlog, BetaBlog}
|
||||
|
||||
setup do
|
||||
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Blogex.RouterTest do
|
||||
use ExUnit.Case
|
||||
import Plug.Test
|
||||
import Plug.Conn, only: [put_req_header: 3]
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.Test.FakeBlog
|
||||
@@ -35,6 +36,18 @@ defmodule Blogex.RouterTest do
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
defp call_html(path) do
|
||||
conn(:get, path)
|
||||
|> put_req_header("accept", "text/html")
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
defp call_json(path) do
|
||||
conn(:get, path)
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
describe "GET /feed.xml" do
|
||||
test "returns RSS XML" do
|
||||
conn = call(:get, "/feed.xml")
|
||||
@@ -153,6 +166,96 @@ defmodule Blogex.RouterTest do
|
||||
end
|
||||
end
|
||||
|
||||
# The browser arms were previously unexercised: every test above goes through
|
||||
# `call/2`, which sends no Accept header, so `wants_html?/1` was always false.
|
||||
describe "GET / (browser)" do
|
||||
test "returns an HTML page with the blog title and the published posts" do
|
||||
conn = call_html("/")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "Test Blog"
|
||||
assert conn.resp_body =~ "First"
|
||||
assert conn.resp_body =~ ~s(href="/blog/test/first-post")
|
||||
refute conn.resp_body =~ "future-post"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["title"] == "Test Blog"
|
||||
assert body["total_entries"] == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /:slug (browser)" do
|
||||
test "returns the post as an HTML page" do
|
||||
conn = call_html("/first-post")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "First"
|
||||
assert conn.resp_body =~ "<p>Post body.</p>"
|
||||
refute conn.resp_body =~ "<p>"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/first-post")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
assert Jason.decode!(conn.resp_body)["id"] == "first-post"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /tag/:tag (browser)" do
|
||||
test "returns HTML with only the posts carrying that tag" do
|
||||
conn = call_html("/tag/elixir")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "First"
|
||||
refute conn.resp_body =~ "Second"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/tag/elixir")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
assert Jason.decode!(conn.resp_body)["tag"] == "elixir"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unknown routes" do
|
||||
test "answers 404 Not found" do
|
||||
conn = call(:post, "/whatever")
|
||||
|
||||
assert conn.status == 404
|
||||
assert conn.resp_body == "Not found"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Helpers.base_url/1" do
|
||||
# `port_suffix/2` has one clause per (scheme, default port) pair plus the
|
||||
# catch-all, and the feeds embed this URL. All four shapes are pinned so a
|
||||
# clause cannot be dropped or reordered without a failure.
|
||||
test "omits the port for the default scheme ports" do
|
||||
assert base_url(:http, 80) == "http://www.example.com"
|
||||
assert base_url(:https, 443) == "https://www.example.com"
|
||||
end
|
||||
|
||||
test "keeps a non-default port" do
|
||||
assert base_url(:http, 4000) == "http://www.example.com:4000"
|
||||
assert base_url(:https, 8443) == "https://www.example.com:8443"
|
||||
end
|
||||
|
||||
defp base_url(scheme, port) do
|
||||
%{Plug.Test.conn(:get, "/") | scheme: scheme, port: port}
|
||||
|> Blogex.Router.Helpers.base_url()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_content_type(conn) do
|
||||
conn
|
||||
|> Plug.Conn.get_resp_header("content-type")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
defmodule BlogexTest do
|
||||
@moduledoc """
|
||||
Behaviour tests for the `Blogex` facade.
|
||||
|
||||
The facade is a thin delegating layer over `Blogex.Registry`, but callers
|
||||
(Phoenix controllers, LiveViews) only ever see the answers, so these tests
|
||||
assert the answers: the exact module lists, post orderings and tag sets the
|
||||
fixtures imply. Asserting `Blogex.x() == Blogex.Registry.x()` would pin the
|
||||
delegation instead of the behaviour and would keep passing if both sides
|
||||
drifted together.
|
||||
|
||||
Non-async: the registry is configured through the process-global `:blogex`
|
||||
application env.
|
||||
"""
|
||||
use ExUnit.Case
|
||||
|
||||
alias Blogex.Test.{AlphaBlog, BetaBlog}
|
||||
|
||||
setup do
|
||||
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
|
||||
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
|
||||
end
|
||||
|
||||
describe "blogs/0" do
|
||||
test "returns the configured blog modules" do
|
||||
assert Blogex.blogs() == [AlphaBlog, BetaBlog]
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_blog!/1 and get_blog/1" do
|
||||
test "return the module registered for a blog_id" do
|
||||
assert Blogex.get_blog!(:alpha) == AlphaBlog
|
||||
assert Blogex.get_blog(:beta) == BetaBlog
|
||||
end
|
||||
|
||||
test "an unknown blog_id returns nil and raises" do
|
||||
assert Blogex.get_blog(:nope) == nil
|
||||
|
||||
assert_raise Blogex.NotFoundError, fn -> Blogex.get_blog!(:nope) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_posts/0" do
|
||||
# Newest first across both blogs: beta's 2026-03-15 post, then alpha's
|
||||
# 2026-03-01. Drafts (a-draft) and future-dated posts (b-future) are not
|
||||
# visible to readers, so neither appears.
|
||||
test "merges published posts from every blog, newest first" do
|
||||
assert ids(Blogex.all_posts()) == ["b1", "a1"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_posts_unfiltered/0" do
|
||||
# Everything, newest first: the 2099 scheduled post, then b1, the alpha
|
||||
# draft, and alpha's oldest.
|
||||
test "includes drafts and future-dated posts, newest first" do
|
||||
assert ids(Blogex.all_posts_unfiltered()) == ["b-future", "b1", "a-draft", "a1"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_tags/0" do
|
||||
test "returns the unique tags across blogs, sorted" do
|
||||
assert Blogex.all_tags() == ["devops", "elixir"]
|
||||
end
|
||||
end
|
||||
|
||||
defp ids(posts), do: Enum.map(posts, & &1.id)
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
defmodule Blogex.CredoConfigTest do
|
||||
@moduledoc """
|
||||
Pins the *policy* in `.credo.exs`, not just the file's presence.
|
||||
|
||||
The gate is `mix credo --strict` (see the `credo` target in `Makefile`), so
|
||||
the config is load-bearing: a check silently re-enabled here changes what
|
||||
every future commit has to satisfy. These assertions are deliberately coarse
|
||||
- "this check sits in that list", "the disable says why" - so they survive
|
||||
credo's own boilerplate churn but not a policy change.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@config_path ".credo.exs"
|
||||
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
|
||||
|
||||
test "cyclomatic complexity is disabled in favour of CRAP" do
|
||||
checks = default_config()[:checks]
|
||||
|
||||
assert @complexity_check in check_modules(checks[:disabled])
|
||||
refute @complexity_check in check_modules(checks[:enabled])
|
||||
end
|
||||
|
||||
test "the disable carries a rationale comment" do
|
||||
rationale = rationale_above(@complexity_check)
|
||||
|
||||
refute rationale == [], "no comment block above the #{@complexity_check} entry"
|
||||
assert Enum.join(rationale, "\n") =~ ~r/crap/i
|
||||
end
|
||||
|
||||
defp default_config do
|
||||
{config, _bindings} = Code.eval_file(@config_path)
|
||||
Enum.find(config.configs, &(&1.name == "default"))
|
||||
end
|
||||
|
||||
defp check_modules(checks) do
|
||||
Enum.map(checks, fn
|
||||
{module, _opts} -> module_name(module)
|
||||
module -> module_name(module)
|
||||
end)
|
||||
end
|
||||
|
||||
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
|
||||
# assertions read better (and diff better) without it.
|
||||
defp module_name(module) do
|
||||
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
|
||||
end
|
||||
|
||||
# The contiguous `#` block immediately above the check's own line. The entry
|
||||
# line is the one that is not itself a comment: a comment mentioning the
|
||||
# check by name must not be mistaken for the entry.
|
||||
defp rationale_above(check_name) do
|
||||
lines = @config_path |> File.read!() |> String.split("\n")
|
||||
|
||||
index =
|
||||
Enum.find_index(lines, fn line ->
|
||||
String.contains?(line, check_name) and not comment?(line)
|
||||
end)
|
||||
|
||||
lines
|
||||
|> Enum.take(index)
|
||||
|> Enum.reverse()
|
||||
|> Enum.take_while(&comment?/1)
|
||||
end
|
||||
|
||||
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
defmodule Blogex.MutationConfigTest do
|
||||
# The mutation sensor is opt-in: a dependency and two aliases, deliberately
|
||||
# absent from `make verify`'s VERIFY_SENSORS. These pins keep the wiring
|
||||
# honest (dev/test only, never runtime) and keep the aliases reading the
|
||||
# scope files rather than duplicating a list in mix.exs.
|
||||
use ExUnit.Case
|
||||
|
||||
test "muex is a dev/test-only, non-runtime dependency" do
|
||||
deps = Mix.Project.config()[:deps]
|
||||
|
||||
assert {:muex, requirement, opts} = dep!(deps, :muex)
|
||||
assert requirement =~ "0.12"
|
||||
assert opts[:only] == [:dev, :test]
|
||||
assert opts[:runtime] == false
|
||||
end
|
||||
|
||||
test "mutate and mutate.fast aliases are defined" do
|
||||
aliases = Mix.Project.config()[:aliases]
|
||||
|
||||
assert is_function(aliases[:mutate], 1)
|
||||
assert is_function(aliases[:"mutate.fast"], 1)
|
||||
end
|
||||
|
||||
test "mutation args always include the scope, the curated mutators and --fail-at 0" do
|
||||
args = Blogex.MixProject.mutation_args("mutation_scope_fast.txt", [])
|
||||
scope = Blogex.MixProject.read_mutation_scope("mutation_scope_fast.txt")
|
||||
|
||||
assert "--fail-at" in args
|
||||
assert Enum.at(args, Enum.find_index(args, &(&1 == "--fail-at")) + 1) == "0"
|
||||
assert files_arg(args) == Enum.join(scope, ",")
|
||||
|
||||
mutators = Enum.at(args, Enum.find_index(args, &(&1 == "--mutators")) + 1)
|
||||
|
||||
assert mutators ==
|
||||
"arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
end
|
||||
|
||||
test "extra CLI args pass through so a single file can be targeted" do
|
||||
args =
|
||||
Blogex.MixProject.mutation_args("mutation_scope_fast.txt", ["--files", "lib/blogex/seo.ex"])
|
||||
|
||||
assert args
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.any?(fn
|
||||
["--files", "lib/blogex/seo.ex"] -> true
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
test "an empty scope fails loudly instead of running over nothing" do
|
||||
path =
|
||||
Path.join(System.tmp_dir!(), "muex-empty-scope-#{System.unique_integer([:positive])}.txt")
|
||||
|
||||
File.write!(path, "\n \n")
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Blogex.MixProject.read_mutation_scope(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp dep!(deps, name) do
|
||||
case Enum.find(deps, fn
|
||||
{^name, _requirement} -> true
|
||||
{^name, _requirement, _opts} -> true
|
||||
_ -> false
|
||||
end) do
|
||||
{^name, requirement} -> {name, requirement, []}
|
||||
{^name, requirement, opts} -> {name, requirement, opts}
|
||||
nil -> flunk("expected #{inspect(name)} in deps/0")
|
||||
end
|
||||
end
|
||||
|
||||
defp files_arg(args) do
|
||||
args
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.find_value(fn
|
||||
["--files", files] -> files
|
||||
_ -> nil
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
defmodule Blogex.MutationScopeTest do
|
||||
# The mutation scope files are the single source of truth for what `mix
|
||||
# mutate` / `mix mutate.fast` are allowed to touch. These pins keep the
|
||||
# sensor honest: a scope that quietly shrinks to an empty (or stale) set
|
||||
# would report a score for a check it never ran.
|
||||
use ExUnit.Case
|
||||
|
||||
@root Path.expand("..", __DIR__)
|
||||
@full "mutation_scope.txt"
|
||||
@fast "mutation_scope_fast.txt"
|
||||
|
||||
# Pure modules only. These are excluded deliberately:
|
||||
# blogex.ex facade + moduledoc, nothing to mutate
|
||||
# blogex/blog.ex the `__using__` macro (NimblePublisher expansion)
|
||||
# router.ex Phoenix router DSL
|
||||
# components.ex Phoenix component DSL
|
||||
# layout.ex Phoenix/LiveView layout DSL
|
||||
@shell_modules [
|
||||
"lib/blogex.ex",
|
||||
"lib/blogex/blog.ex",
|
||||
"lib/blogex/router.ex",
|
||||
"lib/blogex/components.ex",
|
||||
"lib/blogex/layout.ex"
|
||||
]
|
||||
|
||||
test "full scope lists only existing files" do
|
||||
missing = Enum.reject(scope(@full), &File.exists?(Path.join(@root, &1)))
|
||||
|
||||
assert missing == [],
|
||||
"mutation scope names files that do not exist: #{inspect(missing)}"
|
||||
end
|
||||
|
||||
test "full scope is not empty" do
|
||||
assert scope(@full) != []
|
||||
end
|
||||
|
||||
test "fast scope is not empty" do
|
||||
assert scope(@fast) != []
|
||||
end
|
||||
|
||||
test "fast scope is a subset of full scope" do
|
||||
out_of_scope = MapSet.difference(MapSet.new(scope(@fast)), MapSet.new(scope(@full)))
|
||||
|
||||
assert MapSet.size(out_of_scope) == 0,
|
||||
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
|
||||
end
|
||||
|
||||
test "scope excludes the macro and shell modules" do
|
||||
scoped = MapSet.new(scope(@full) ++ scope(@fast))
|
||||
present = Enum.filter(@shell_modules, &MapSet.member?(scoped, &1))
|
||||
|
||||
assert present == [],
|
||||
"shell/macro modules belong outside the mutation scope: #{inspect(present)}"
|
||||
end
|
||||
|
||||
defp scope(file) do
|
||||
@root
|
||||
|> Path.join(file)
|
||||
|> File.read!()
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
defmodule Blogex.Test.AlphaBlog do
|
||||
@moduledoc """
|
||||
A minimal blog module for cross-blog tests.
|
||||
|
||||
Implements the interface `Blogex.Registry` queries (and nothing else), so a
|
||||
test can configure `:blogex, :blogs` with real modules without compiling a
|
||||
`use Blogex.Blog` module or touching the filesystem.
|
||||
|
||||
`all_posts/0`/`unfiltered_posts/0` are hard-coded — the dates are literals so
|
||||
tests can assert exact orderings rather than re-deriving them.
|
||||
"""
|
||||
|
||||
alias Blogex.Test.PostBuilder
|
||||
|
||||
def blog_id, do: :alpha
|
||||
|
||||
def all_posts,
|
||||
do: [PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_tags, do: ["elixir"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
|
||||
PostBuilder.build(
|
||||
id: "a-draft",
|
||||
date: ~D[2026-03-05],
|
||||
blog: :alpha,
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
defmodule Blogex.Test.BetaBlog do
|
||||
@moduledoc """
|
||||
The second blog module for cross-blog tests; see `Blogex.Test.AlphaBlog`.
|
||||
"""
|
||||
|
||||
alias Blogex.Test.PostBuilder
|
||||
|
||||
def blog_id, do: :beta
|
||||
|
||||
def all_posts,
|
||||
do: [PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_tags, do: ["devops"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
|
||||
PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
|
||||
]
|
||||
end
|
||||
@@ -5,6 +5,8 @@ defmodule Blogex.Test.Setup do
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
alias Blogex.Test.FakeBlog
|
||||
|
||||
@doc """
|
||||
Starts a FakeBlog with a standard set of posts.
|
||||
Returns `%{blog: module, posts: posts}`.
|
||||
@@ -13,9 +15,9 @@ defmodule Blogex.Test.Setup do
|
||||
posts = Keyword.get(opts, :posts, default_posts())
|
||||
blog_opts = Keyword.drop(opts, [:posts])
|
||||
|
||||
{:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts)
|
||||
{:ok, _} = FakeBlog.start(posts, blog_opts)
|
||||
|
||||
Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts})
|
||||
Map.merge(context, %{blog: FakeBlog, posts: posts})
|
||||
end
|
||||
|
||||
@doc "A small set of posts covering common scenarios."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 844 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,243 @@
|
||||
# Improve verification for firehose
|
||||
|
||||
Plan for bringing the "internal code health" sensor stack to firehose, based on
|
||||
what worked in stickies (Oct 2026) and
|
||||
[Velasco's ExCrap blog post](https://www.germanvelasco.com/blog/i-wrote-excrap-without-looking-at-the-code).
|
||||
|
||||
Two kinds of verification (the post's frame):
|
||||
|
||||
1. **External** — is the product what we wanted? Visible: the site, the blogs.
|
||||
Firehose already has decent coverage here: Playwright suites for the app
|
||||
(`make playwright*`), controller/LiveView tests.
|
||||
2. **Internal** — is the codebase healthy and evolving well? Invisible unless
|
||||
we install **sensors and signals**: coverage, CRAP scores, credo, sobelow,
|
||||
mutation testing, boundary checks. This plan is about the sensors.
|
||||
|
||||
## Where firehose differs from stickies
|
||||
|
||||
- **Two OTP apps.** `app/` (Phoenix, the Firehose site) and `blogex/`
|
||||
(publishable library, NimblePublisher-based blog engine, no DB). Every sensor
|
||||
must run per-app; the root `Makefile` orchestrates (`make test` already runs
|
||||
both).
|
||||
- **blogex is a library** (has `package`/`docs` config). Quality bar is
|
||||
higher; it's also mostly *pure* code (post parsing, link validation, feeds,
|
||||
SEO) — the ideal mutation-testing target, like stickies' `lib/cardgame/domain`.
|
||||
- **blogex has no mix aliases at all** (no precommit, no format in aliases).
|
||||
`app` has a `precommit` alias (compile --warnings-as-errors, deps.unlock,
|
||||
format, test).
|
||||
- **A gitea remote already exists** — unlike stickies at the start, a pre-push
|
||||
hook will actually fire on every push from day one.
|
||||
- Already present: credo dep in `app` (default config, not tuned/strict);
|
||||
nothing else yet. No coverage config, no boundary/ex_crap/sobelow/muex in
|
||||
either app. Current suites green: app 178 tests, blogex 156.
|
||||
- `AGENTS.md` has a **zero-defects policy** ("never continue when there are
|
||||
errors") — the fast/push split must be written to fit that policy: Lens
|
||||
failures are triaged and reported, never ignored (see triage classes below).
|
||||
|
||||
## Phases
|
||||
|
||||
Order matters: the first yak is the *container* the other sensors wire into
|
||||
(same conclusion as stickies — `pre-push-verification-loop-and-lens-review-practice`).
|
||||
|
||||
### Phase 1 — Fast/push split (the container)
|
||||
|
||||
Keep `precommit` cheap and fast (commit freely); move heavy checks to a
|
||||
**Lens pass** = `make verify` + `.githooks/pre-push`.
|
||||
|
||||
- [ ] Root `Makefile`: `verify` target that loops over `app` and `blogex`,
|
||||
running the heavy checks cheapest-first (credo → sobelow → coverage+CRAP).
|
||||
- [ ] `.githooks/pre-push` + `core.hooksPath=.githooks` (wire into `make`/setup).
|
||||
Fail loudly with a summary; trivially skippable via `git push --no-verify`.
|
||||
- [ ] `blogex/mix.exs`: add an `aliases` block with `precommit`
|
||||
(compile --warnings-as-errors, format, test) so both apps share the loop.
|
||||
- [ ] AGENTS.md: write the **Lens practice** down — review is a separate pass
|
||||
with its own context; don't chase hook nits inline while building; file a
|
||||
yak instead. Add the **triage classes** for Lens failures:
|
||||
*real bug* (fix inline) / *environment-dependent test* (the test is the
|
||||
bug — make it deterministic by construction, no ambient state as
|
||||
fixtures) / *style or design nit* (note or file a yak). Reconcile with
|
||||
the zero-defects policy: "never continue silently" ≠ "fix nits inline";
|
||||
it means report, triage, decide.
|
||||
- [ ] Watch hook runtime: `app`'s `test` alias drops/creates/migrates the DB
|
||||
every run. If the pre-push run re-tests, suite time doubles. Consider a
|
||||
`test.crap` alias reusing an exported coverage run (stickies:
|
||||
`test --cover --export-coverage default` + `test.coverage` + `crap`).
|
||||
|
||||
### Phase 2 — Coverage floor + exclusions policy
|
||||
|
||||
- [ ] Add `test_coverage: [threshold: N]` to **both** `mix.exs` files
|
||||
(blogex first — it's pure and should be near-perfect; app likely lower).
|
||||
Run coverage once to see actual numbers before picking the floor.
|
||||
- [ ] Make the **exclusions policy decision early**, not later (in stickies
|
||||
this came late, commit `af12c2d`): exclude framework-owned glue
|
||||
(`Firehose.Repo`, `ErrorHTML/JSON`, `Telemetry`, `Mailer`) — counting it
|
||||
inflates the floor without reflecting app behavior.
|
||||
- [ ] Rule from stickies: the threshold is a **floor, ratcheted up as yaks
|
||||
land** — never lowered.
|
||||
|
||||
### Phase 3 — Behavior tests for the gaps (the bulk of the work)
|
||||
|
||||
Run per-app coverage, list the uncovered modules, file yaks per area
|
||||
(stickies: "behavior tests: boundary shell", "…: web LiveViews", "…:
|
||||
DevToolsPage"). See *Steering the model on missing tests* below for the
|
||||
principles — this is where most steering happened.
|
||||
|
||||
Expected gaps in firehose, by module type:
|
||||
|
||||
- `blogex/*` — pure library, should be drivable to very high coverage through
|
||||
behavior tests (post → rendered HTML, invalid links → errors, feed output).
|
||||
- `app/lib/firehose/accounts*` — context behavior (stickies did this for
|
||||
MultiTenancy: narrate from the actors' view).
|
||||
- `firehose_web/live/*` and controllers — outcome tests through
|
||||
`Phoenix.LiveViewTest` / conn tests, not HTML-string assertions.
|
||||
- Editorial features (`editor_dashboard_live`, `microprints_live`) — likely the
|
||||
thinnest coverage; behavior-test them like stickies' DevToolsPage.
|
||||
|
||||
- [ ] Coverage-driven yak list per app; work them one by one.
|
||||
- [ ] After each yak: **ratchet the floor** up to the new total + small margin.
|
||||
|
||||
### Phase 4 — CRAP scores with ex_crap
|
||||
|
||||
- [ ] Add `{:ex_crap, "~> 0.1", only: [:dev, :test], runtime: false}` to both
|
||||
apps. **Sandbox gotcha:** `mix deps.get` cannot fetch new hex packages
|
||||
from inside the sandbox — edit `mix.exs`, then ask the user to run
|
||||
`mix deps.get` outside; stop there. Same for every dep below.
|
||||
- [ ] Wire `mix crap` into the Lens pass (needs the coverage export from
|
||||
Phase 1's `test.crap` alias).
|
||||
- [ ] Baseline at default `--max-score 30`. Fix offenders the stickies way:
|
||||
* pure data refactor where the complexity is real logic (9-branch
|
||||
`case` → compile-time lookup map);
|
||||
* for shell glue (LiveView `handle_event`), **close coverage gaps
|
||||
instead of refactoring** — a fully-covered complexity-22 function
|
||||
scores 24.48, under the default threshold.
|
||||
- [ ] CRAP formula reminder: `comp^2*(1-cov)^3 + comp`; coverage ≥ ~75% of a
|
||||
comp-22 function clears threshold 30.
|
||||
|
||||
### Phase 5 — Credo strict, tuned
|
||||
|
||||
- [ ] Tune `.credo.exs` (both apps; blogex has none): **defaults, zero
|
||||
findings under `--strict` at introduction** — the config is the policy.
|
||||
Deliberate deviation: disable `Credo.Check.Refactor.CyclomaticComplexity`
|
||||
in favor of CRAP scores (a wide `case` with good coverage is not a
|
||||
problem; that was the original itch behind ExCrap).
|
||||
- [ ] Runs in the Lens pass, not precommit.
|
||||
- [ ] When strict flags code: refactor along **pure-data lines** (lookup
|
||||
tables, small pure classifiers, pure helpers that return values) — never
|
||||
split orchestration/shell code into async calls or scattered processes
|
||||
just to satisfy a metric (user, 2026-10-10: "I don't want an agent to
|
||||
arbitrarily break them down, but respect our design principles").
|
||||
|
||||
### Phase 6 — Sobelow
|
||||
|
||||
- [x] Add to the Lens pass for `app` (Phoenix surface). blogex is a library
|
||||
without endpoints; decide whether to run it there anyway — in ex_crap's
|
||||
own history sobelow caught a `String.to_atom` in a library, so it's not
|
||||
strictly web-only value. Decided 2026-10-28: **app only**; blogex's
|
||||
sobelow scope is a separate yak.
|
||||
- [x] **Fail when sobelow is not installed** (stickies: user explicitly
|
||||
removed the skip — a silently-skipped check is a lying sensor).
|
||||
`make -C app sobelow` has no fallback: a missing task is a hard Mix
|
||||
error, and `verify`'s `|| exit 1` loop fails the pass.
|
||||
- [x] Fix findings or add per-check skips *with rationale comments*
|
||||
(stickies: `Config.CSP` skipped because the plug is invisible to sobelow).
|
||||
Baseline 2026-10-28 (sobelow 0.17.0), 3 findings: `Config.HTTPS` (High),
|
||||
`Config.CSP` (High), `Traversal.FileModule` (Low). All three are skipped
|
||||
in `app/.sobelow-skips`, each with a written rationale: HTTPS and CSP are
|
||||
deployment/design decisions with follow-up yaks, and the Traversal flag
|
||||
is a false positive (the path is matched against the server-scanned list
|
||||
before `File.read`). Unlike stickies, the CSP skip is *not* "the plug is
|
||||
invisible" — this app genuinely has no CSP yet.
|
||||
|
||||
### Phase 7 — Boundary
|
||||
|
||||
Different shape here than in stickies:
|
||||
|
||||
- The **app/blogex split already enforces the biggest boundary** via the mix
|
||||
dep (`{:blogex, path: "../blogex"}`): app code can only use blogex's public
|
||||
API. The library direction is naturally clean.
|
||||
- [ ] Inside `app`: declare boundaries for `Firehose` (contexts),
|
||||
`FirehoseWeb` (exports Endpoint/Telemetry), and make **`Firehose.Repo` a
|
||||
top-level sibling boundary with `deps: []`**, removed from `Firehose`'s
|
||||
exports — so web→Repo calls become compile-time violations, not
|
||||
convention (stickies commits `35f2eff`, `337a0c8`).
|
||||
- [ ] Enable the boundary compiler in `:dev` **and** `:test` so precommit's
|
||||
`compile --warning-as-errors` gates on violations. Watch for
|
||||
unclassified-module warnings from test support modules (declare an
|
||||
ignored top-level boundary for them, as stickies did for `CardgameTest`).
|
||||
- [ ] External apps whitelist: `[:ecto, :phoenix, :plug, :phoenix_live_view]`
|
||||
with per-module lists where boundary splits them.
|
||||
- [ ] *Optional, not used in stickies:* Velasco's `boundary.spec.check` /
|
||||
`boundary.spec.accept` tasks — an agent-proof sensor that fails when the
|
||||
boundary definition itself changed without human approval. Cheap to add
|
||||
once boundaries exist; worth considering given firehose's zero-defects
|
||||
culture.
|
||||
|
||||
### Phase 8 — Mutation testing (muex)
|
||||
|
||||
- [ ] Add muex (dev-only, `runtime: false`) + `mix mutate` / `mix mutate.fast`
|
||||
aliases. **Scope tightly to pure code**: `blogex/lib/**` (link_validator,
|
||||
seo, feed, post parsing) and any pure modules in `app` — stickies scoped
|
||||
to `lib/cardgame/{domain,events}` and left shell code alone.
|
||||
- [ ] Curated mutator set: `arithmetic,boolean,comparison,conditional,
|
||||
function_call,literal,return_value` (excludes macro/use-based mutators —
|
||||
in ex_crap itself, `use Boundary` removals produced false-positive
|
||||
survivors).
|
||||
- [ ] `--fail-at 0`: mutation testing stays a **sensor, not a gate**; documented
|
||||
false positives (guard `and`↔`or` mutants, removed log lines) are noted,
|
||||
not chased.
|
||||
- [ ] Genuine survivors → new tests that pin the behavior (e.g. per-field
|
||||
error tests pinning chained guards).
|
||||
|
||||
### Phase 9 — Ratchets, decisions, handoff
|
||||
|
||||
- [ ] Ratchet coverage floors after each behavior yak lands.
|
||||
- [ ] Decide (with the user, don't assume): CRAP `--max-score` below 30?
|
||||
boundary.spec.check adoption? Playwright in the Lens pass or kept as a
|
||||
separate external-verification loop?
|
||||
- [ ] "Landing the plane": after each session with code changes, run
|
||||
`make precommit` always; run `make verify` (or push, which fires the
|
||||
hook) before handing off.
|
||||
|
||||
## Steering the model on missing tests (what made stickies work)
|
||||
|
||||
The sensors tell you *where* tests are missing; the steering below is what
|
||||
made the resulting tests good rather than line-chasing. Recorded from the
|
||||
stickies session history (Oct 10–12) so it can be pasted into firehose
|
||||
sessions or this repo's AGENTS.md:
|
||||
|
||||
1. **Behavior, not state.** Narrate tests from the actors' view — "host starts
|
||||
a team, members join, roles change, members leave" — and assert *outcomes*
|
||||
(the next actor's experience), not internal struct/assign shapes. Pin
|
||||
current behavioral decisions explicitly when a test does so (e.g. "same-name
|
||||
teams cannot both exist").
|
||||
2. **Functional core, imperative shell.** Details of persistence (how joining
|
||||
works internally) stay out of most tests; the test drives the public API of
|
||||
the core. GenServers/stores get *outcome* tests (persistence failure → state
|
||||
unchanged; restart → replay), not process-internals tests.
|
||||
3. **Don't test dead code — delete it.** Coverage gaps that are unreachable
|
||||
mount branches (plug guarantees the precondition) are removed with a comment
|
||||
stating the contract, not tested.
|
||||
4. **Leave genuinely defensive branches uncovered, deliberately.** Store
|
||||
failure paths only reachable by DB failure, fallbacks the router guarantees
|
||||
— chasing them with artificial mocks tests lines, not behavior. Note them
|
||||
in the yak context so the next ratchet isn't confused.
|
||||
5. **Refactor along pure-data lines when a metric flags code**; never split a
|
||||
linear shell script into async tasks to satisfy a metric. A flat lookup
|
||||
`case` that is genuinely clearer than a table may stay — note it, move on.
|
||||
6. **Don't chase Lens nits inline** while building; file a yak, let the Lens
|
||||
pass handle them.
|
||||
7. **Sandbox:** new hex deps need the user to run `mix deps.get` outside the
|
||||
sandbox (read-only `~/.hex/packages`). Never work around it.
|
||||
|
||||
## Suggested yak list (for `yx` in firehose)
|
||||
|
||||
1. Fast/push split: make verify + pre-push hook + Lens practice in AGENTS.md
|
||||
2. Coverage floors + framework-glue exclusions policy (both apps)
|
||||
3. Behavior tests: blogex library (probably 2–3 yaks by area)
|
||||
4. Behavior tests: accounts context
|
||||
5. Behavior tests: firehose_web LiveViews/controllers
|
||||
6. CRAP scores with ex_crap (both apps)
|
||||
7. Credo strict tuned config (both apps)
|
||||
8. Sobelow in the Lens pass
|
||||
9. Boundary: Repo as sibling boundary, web→Repo forbidden
|
||||
10. Mutation testing with muex (blogex + pure app code)
|
||||
@@ -0,0 +1,163 @@
|
||||
# Mutation testing with muex
|
||||
|
||||
Mutation testing is a **sensor, not a gate**. It deliberately breaks
|
||||
production code one mutant at a time and re-runs the suite: a mutant *killed*
|
||||
by a test is evidence the test pins that behaviour; a mutant that *survives*
|
||||
is a question for a Lens session. Runs always pass `--fail-at 0`, so a survivor
|
||||
is reported and triaged, never a failing build.
|
||||
|
||||
Nothing here is wired into `VERIFY_SENSORS`. `make check`, `make verify`,
|
||||
coverage floors and `.githooks/pre-push` are unchanged by this yak.
|
||||
|
||||
## How to run it
|
||||
|
||||
Both apps declare two aliases in `mix.exs`; both read a plain-text scope file
|
||||
at run time, so the file stays the single source of truth.
|
||||
|
||||
| Command | Scope | Blogex wall clock (2026-10-28) |
|
||||
|---------|-------|--------------------------------|
|
||||
| `mix mutate.fast` | `mutation_scope_fast.txt` (3 modules) | ~3 s |
|
||||
| `mix mutate` | `mutation_scope.txt` (8 modules) | ~4 s |
|
||||
|
||||
The app declares the same aliases but both scope files are empty (see
|
||||
"Scope: app" below), so `mix mutate` there raises a clear Mix error instead of
|
||||
running a sensor over nothing.
|
||||
|
||||
Extra arguments pass through to muex, so a one-off target is explicit:
|
||||
|
||||
```bash
|
||||
cd blogex
|
||||
mix mutate --files lib/blogex/seo.ex # one module, ad hoc
|
||||
mix mutate.fast --no-optimize # full mutant list, slower
|
||||
```
|
||||
|
||||
The aliases pin `--fail-at 0` first, so an ad-hoc flag cannot turn the sensor
|
||||
into a gate. `mix mutate` does not enable `--cover`: muex runs each mutant's
|
||||
suite under `MIX_ENV=test`, and coverage would let a code-deleting mutant trip
|
||||
the project's coverage floor and abort the run.
|
||||
|
||||
## Scope: blogex
|
||||
|
||||
| In scope (`mutation_scope.txt`) | Why |
|
||||
|---------------------------------|-----|
|
||||
| `lib/blogex/link_validator.ex` | Pure link parsing/validation; the yak's example module. |
|
||||
| `lib/blogex/seo.ex` | Pure sitemap/JSON-LD/OpenGraph string building. |
|
||||
| `lib/blogex/feed.ex` | Pure feed generation. |
|
||||
| `lib/blogex/post.ex` | Pure post parsing/visibility helpers. |
|
||||
| `lib/blogex/external_links.ex` | Pure HTML rewriting for external links. |
|
||||
| `lib/blogex/registry.ex` | Module-registry lookup; no I/O. |
|
||||
| `lib/blogex/not_found_error.ex` | Exception definition. |
|
||||
| `lib/blogex/link_error.ex` | Exception definition. |
|
||||
|
||||
| Out of scope | Why |
|
||||
|--------------|-----|
|
||||
| `lib/blogex.ex` | Facade + moduledoc: delegation and docs, no logic to mutate. |
|
||||
| `lib/blogex/blog.ex` | A `defmacro __using__` that expands `NimblePublisher`; mutating macro bodies produces noise, not behaviour. |
|
||||
| `lib/blogex/router.ex` | Phoenix router DSL (`use`, `scope`, `get`); macro-generated. |
|
||||
| `lib/blogex/components.ex` | Phoenix component DSL (`use`, `attr`, `slot`, `~H`). |
|
||||
| `lib/blogex/layout.ex` | LiveView layout DSL. |
|
||||
|
||||
`mutation_scope_fast.txt` is a subset (`link_validator`, `seo`, `post`) so the
|
||||
inner loop stays seconds long; a pin in
|
||||
`test/mutation_scope_test.exs` fails if it ever drifts outside the audited
|
||||
scope. Another pin fails if any scoped path stops existing, so a rename cannot
|
||||
silently leave the sensor reporting a score for a reduced set.
|
||||
|
||||
## Mutators
|
||||
|
||||
The aliases pass an explicit curated set (muex 0.12 CLI names):
|
||||
|
||||
```
|
||||
arithmetic, boolean, comparison, conditional, function_call, literal, return_value
|
||||
```
|
||||
|
||||
Macro/`use`-based mutators are deliberately absent. The scoped modules are plain
|
||||
functions, and the macro-heavy modules are out of scope; macro mutations produce
|
||||
false-positive survivors rather than behavioural questions. The `arithmetic`,
|
||||
`boolean`, `comparison` and `conditional` families are the behavioural core;
|
||||
`function_call`, `literal` and `return_value` catch removed calls, changed
|
||||
constants and stubbed return values.
|
||||
|
||||
## Baseline (blogex, 2026-10-28)
|
||||
|
||||
Command: `mix mutate` (default muex optimization, `--fail-at 0`).
|
||||
Result: **19 mutants, 12 killed, 0 survived, 0 invalid, 7 equivalent — 100%.**
|
||||
Wall clock: 3.5 s. The run that found the survivor below was 11 killed / 1
|
||||
survived (91.67%) before its pin landed.
|
||||
|
||||
| Module | Mutants | Killed | Survived | Equivalent |
|
||||
|--------|--------:|-------:|---------:|-----------:|
|
||||
| `link_validator.ex` | 7 | 4 | 0 | 3 |
|
||||
| `external_links.ex` | 6 | 5 | 0 | 1 |
|
||||
| `registry.ex` | 4 | 1 | 0 | 3 |
|
||||
| `post.ex` | 1 | 1 | 0 | 0 |
|
||||
| `seo.ex` | 1 | 1 | 0 | 0 |
|
||||
| `feed.ex` | 0 | 0 | 0 | 0 |
|
||||
| `not_found_error.ex` | 0 | 0 | 0 | 0 |
|
||||
| `link_error.ex` | 0 | 0 | 0 | 0 |
|
||||
|
||||
muex's file analyser gives `not_found_error.ex` a complexity score of 0 and
|
||||
skips it under the default filter. The scope file still lists it: it is pure
|
||||
code and stays audited if it grows logic. `feed.ex` is analysed but the curated
|
||||
mutator set generates nothing for it.
|
||||
|
||||
### Survivors found and killed
|
||||
|
||||
| Mutant | Resolution |
|
||||
|--------|------------|
|
||||
| `link_validator.ex:201` — `Boolean: and to or` in `validate_body/3`'s guard `when is_binary(body) and is_atom(blog)` | Genuine: the mutant let a string blog id through the guard. Pinned by two `FunctionClauseError` tests in `test/blogex/link_validator_test.exs`; `mix mutate.fast` then reports 0 survivors. |
|
||||
|
||||
## False-positive classes
|
||||
|
||||
These are not gaps in behaviour; do not weaken a test to "kill" them.
|
||||
|
||||
- **Commutative argument swaps.** `function_call` mutates `a and b` to
|
||||
`b and a`, `a == b` to `b == a`, and `a || b` to `b || a`. These compile to
|
||||
the same behaviour, and muex's trivial-compiler-equivalence check marks them
|
||||
`equivalent` (7 of the 19 baseline mutants). They are noise from the
|
||||
mutator, not survivors.
|
||||
- **Removed log lines.** A removed `Logger` call changes no return value; the
|
||||
suite cannot observe it. Such a mutant is documented here instead of adding
|
||||
a log-capture assertion that pins an implementation detail.
|
||||
- **`and`/`or` in guards is *not* automatically a false positive.** The
|
||||
baseline survivor above was a real guard-contract gap; only commutative
|
||||
rewrites are equivalent.
|
||||
- **Invalid mutants.** Mutated code that does not compile is counted
|
||||
`invalid`, not `survived`, and excluded from the score.
|
||||
|
||||
## Deeper runs
|
||||
|
||||
muex's default `balanced` optimization reduced 1304 generated mutants to 12
|
||||
tested ones (99.1%) on the blogex full scope. That is what keeps the sensor in
|
||||
seconds, but it is sampled, not exhaustive. `mix mutate --no-optimize` tests
|
||||
everything; on the three-module fast scope it took ~2 min 15 s and produced
|
||||
434 mutants (329 killed, 19 survived, 83 invalid, 3 equivalent) before the
|
||||
guard pin. Use it when triaging a specific module, not in a loop.
|
||||
|
||||
Re-baseline after a yak that changes the scoped modules (notably the Boundary
|
||||
work, which adds `use` lines inside them); do not compare scores across such a
|
||||
change.
|
||||
|
||||
## Scope: app
|
||||
|
||||
**Deliberately empty; no app baseline was run.** The candidate pure modules
|
||||
are `Firehose.Accounts.User`, `Firehose.Accounts.Scope` and
|
||||
`Firehose.Accounts.UserToken`, but every test that exercises them lives in
|
||||
`test/firehose/accounts_test.exs`, which uses `Firehose.DataCase`, fixtures and
|
||||
`Repo`; the SQL sandbox needs PostgreSQL. The yak's Q45 decision was: use those
|
||||
modules only after confirming their tests need no database, otherwise leave the
|
||||
app scope empty. They need one, so the scope is empty.
|
||||
|
||||
Consequences, all pinned by `app/test/mutation_scope_test.exs` and
|
||||
`app/test/mutation_config_test.exs`:
|
||||
|
||||
- Both app scope files list nothing, so `mix mutate` / `mix mutate.fast` raise
|
||||
`mutation scope … is empty; refusing to run the mutation sensor over nothing`.
|
||||
- Ad-hoc `--files` arguments do not bypass the guard: the scope is validated
|
||||
before muex arguments are built.
|
||||
- No scope entry may ever begin with `lib/firehose_web` (controllers,
|
||||
LiveViews, components).
|
||||
|
||||
There is no app mutation baseline to report. Do not add one until the scoped
|
||||
modules have tests that run without the database; change the scope file and
|
||||
these pins in the same commit.
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-lens-sensors.sh - pin the Lens sensor wiring that must not drift.
|
||||
#
|
||||
# Why this exists: `make verify` echoes VERIFY_SENSORS and runs each name from
|
||||
# the app's sensor loop. A sensor missing from that list is never run, and a
|
||||
# sensor target that can fall back (or skip the tool when it is absent) exits 0
|
||||
# when it did not check anything - in both cases the pass reports a check it
|
||||
# did not run. AGENTS.md: "A gate that reports a check it did not run is worse
|
||||
# than no gate."
|
||||
#
|
||||
# This script is static and grep-only: no mise, no elixir, no network, no
|
||||
# database. It pins load-bearing text (the VERIFY_SENSORS line and the sobelow
|
||||
# recipe) and the rationale rule for per-finding skips. Renaming the target or
|
||||
# the variable means updating this script in the same commit - that is the
|
||||
# point: the wiring stays a reviewed decision rather than an accident.
|
||||
#
|
||||
# Run standalone, or via root `make check`.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
app_makefile="$root/app/Makefile"
|
||||
skips="$root/app/.sobelow-skips"
|
||||
status=0
|
||||
|
||||
# Verified 2026-10-28 against sobelow 0.17.0:
|
||||
# `cd app && mix sobelow` with 3 findings -> exit 0 (findings alone are NOT
|
||||
# fatal); `cd app && mix sobelow --exit` -> exit 1. `--exit` defaults to the
|
||||
# `low` confidence threshold, which is what we want: every finding counts.
|
||||
SOBELOW_FAIL_FLAG='--exit'
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; status=1; }
|
||||
|
||||
# --- T1: sobelow is wired and listed first in VERIFY_SENSORS ---------------
|
||||
# `make verify` echoes this list, so a missing name means the pass reports a
|
||||
# sensor it never runs. Cheapest-first ordering is part of the contract.
|
||||
check_verify_sensors() {
|
||||
local line sensors i
|
||||
line=$(grep -E '^VERIFY_SENSORS[[:space:]]*:=' "$app_makefile" || true)
|
||||
if [ -z "$line" ]; then
|
||||
fail "app/Makefile has no VERIFY_SENSORS := line"
|
||||
return
|
||||
fi
|
||||
|
||||
sensors=$(printf '%s' "${line#*:=}" | tr -s '[:space:]' ' ')
|
||||
local -a order
|
||||
read -r -a order <<<"$sensors"
|
||||
|
||||
idx() {
|
||||
local n
|
||||
for n in "${!order[@]}"; do
|
||||
if [ "${order[$n]}" = "$1" ]; then
|
||||
echo "$n"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo 999
|
||||
}
|
||||
|
||||
for sensor in sobelow coverage crap; do
|
||||
[ "$(idx "$sensor")" -ne 999 ] || fail "VERIFY_SENSORS does not list '$sensor' (line: $line)"
|
||||
done
|
||||
[ "$(idx sobelow)" -lt "$(idx coverage)" ] ||
|
||||
fail "VERIFY_SENSORS puts coverage before sobelow (cheapest first)"
|
||||
[ "$(idx sobelow)" -lt "$(idx crap)" ] ||
|
||||
fail "VERIFY_SENSORS puts crap before sobelow (cheapest first)"
|
||||
}
|
||||
|
||||
# --- T2: the sobelow target fails loudly and has no skip/fallback path -----
|
||||
# The stickies regression: the user explicitly removed a "not installed,
|
||||
# skipping" fallback because a silently-skipped check is a lying sensor.
|
||||
check_sobelow_recipe() {
|
||||
local recipe
|
||||
recipe=$(awk '
|
||||
/^sobelow:/ { found = 1; next }
|
||||
found && /^\t/ { print; next }
|
||||
found { exit }
|
||||
' "$app_makefile")
|
||||
|
||||
if [ -z "$recipe" ]; then
|
||||
fail "app/Makefile has no sobelow: target with a recipe"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$recipe" in
|
||||
*"mix sobelow"*) ;;
|
||||
*) fail "sobelow recipe does not invoke 'mix sobelow': $recipe" ;;
|
||||
esac
|
||||
|
||||
case "$recipe" in
|
||||
*"$SOBELOW_FAIL_FLAG"*) ;;
|
||||
*) fail "sobelow recipe is missing $SOBELOW_FAIL_FLAG, so findings would exit 0: $recipe" ;;
|
||||
esac
|
||||
|
||||
local banned
|
||||
for banned in '||' 'command -v sobelow' 'skipping' '2>/dev/null'; do
|
||||
case "$recipe" in
|
||||
*"$banned"*) fail "sobelow recipe contains a fallback/skip path ('$banned'): $recipe" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# --- T3: every per-finding skip carries a written rationale ----------------
|
||||
# Sobelow 0.17 keeps per-finding skips as fingerprinted lines in
|
||||
# `app/.sobelow-skips` (enabled with `--skip`); `.sobelow-conf` holds only
|
||||
# options. A skip entry without a reason is a defect: the next reader cannot
|
||||
# tell a false positive from a debt someone hid. Comments are preserved by
|
||||
# sobelow's skip loader (they sort first and never match a fingerprint).
|
||||
check_skips() {
|
||||
[ -f "$skips" ] || return 0
|
||||
|
||||
local prev="" line n=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n + 1))
|
||||
case "$line" in
|
||||
"" | "#"*)
|
||||
prev="$line"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
case "$prev" in
|
||||
"#"*) ;;
|
||||
*) fail "app/.sobelow-skips:$n: skip entry has no rationale comment on the line above: $line" ;;
|
||||
esac
|
||||
prev="$line"
|
||||
done <"$skips"
|
||||
}
|
||||
|
||||
check_verify_sensors
|
||||
check_sobelow_recipe
|
||||
check_skips
|
||||
|
||||
if [ $status -eq 0 ]; then
|
||||
echo "lens sensors ok: sobelow wired first in VERIFY_SENSORS, fail-loud recipe, app skips rationalised"
|
||||
else
|
||||
echo >&2 "fix the Lens sensor wiring/skips above, then re-run."
|
||||
fi
|
||||
exit $status
|
||||
Reference in New Issue
Block a user