examples: todos.sh, its fixtures in todos.md, and a table-driven test that make test runs. And a sizable increase in the readme.
commit 9a69562881b0ad3e6e462ee2cae03c54b7f0958c · parent c972b45c76
· browse this tree
Ximianth, 2026-10-24 01:14:28 UTC
The test shows the patterns users should copy: one case per hunk name pair, normalizing the temp dir out of the output before an exact check, feeding the check with < <(...) so it runs in this shell, and testmark_write at the end so TESTMARK_REGEN=1 can update the fixtures.
diff --git a/Makefile b/Makefile index a8af5cd..72d5216 100644 --- a/Makefile +++ b/Makefile @@ -24,8 +24,9 @@ fmt: ## Format source in place. .PHONY: check check: ## Verify-only and fast; run by the pre-commit hook on every commit. shfmt -d . - shellcheck -x -S warning lib/*.bash lib/*/*.bash tests/*.sh + shellcheck -x -S warning lib/*.bash lib/*/*.bash tests/*.sh examples/*.sh .PHONY: test test: ## Run all tests. tests/run.sh + examples/test-todos.sh diff --git a/README.md b/README.md index ced3572..0cb2538 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,147 @@ -PLACEHOLDER -========= +testmark-bash +============= + +testmark-bash reads, checks, and patches [testmark](https://testmark.dev/) documents, from bash. + +Testmark is data in markdown: a fenced code block, named by a comment line just above it. +That makes a markdown file a good home for test fixtures: +the prose around them explains them, they render nicely anywhere markdown does, and a program can rewrite them. + +So with this library, a bash test can check a tool's output against a block in a markdown file; +and when the output changes on purpose, rewrite the block, and leave you a `git diff` to review. + +```bash +source path/to/lib/testmark.bash + +testmark_parse doc fixtures.md +testmark_check doc greeting/output < <(mytool greet world) +testmark_write doc +``` + +It needs bash 4.4 or later. + + +The workflow +------------ + +Write the input, and an empty block for the output to go in: + +``` + [testmark]:# (basic/input) + ```text + # TODO: handle spaces in names + ``` + + [testmark]:# (basic/output) + ```text + ``` +``` + +Run the test. It fails, and shows a diff of what your tool printed against the empty block. + +Run it again with `TESTMARK_REGEN=1` set. The output gets written into the block. + +Read it with `git diff`. If it's right, commit it. + +Later, when the tool's output changes on purpose, it's the same: regen, read the diff, commit. +The diff is the review. + + +A whole example +--------------- + +[`examples/`](examples/) has a small tool, `todos.sh`, a fixtures doc for it, `todos.md`, and this test, which `make test` runs: + +```bash +#!/bin/bash +## Checks todos.sh against every case in todos.md. +## Run with TESTMARK_REGEN=1 to update todos.md to match what todos.sh does now; then review the change with `git diff`. +set -euo pipefail +cd "$(dirname "$0")" +source ../lib/testmark.bash + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +testmark_parse doc todos.md +mapfile -t names < <(testmark_names doc) +fails=0 +for name in "${names[@]}"; do + [[ $name == */input ]] || continue + case=${name%/input} + testmark_body doc "$case/input" >"$work/notes.txt" + if testmark_check doc "$case/output" < <(./todos.sh "$work/notes.txt" | sed "s|$work|WORK|g"); then + echo "PASS $case" + else + echo "FAIL $case" + fails=$((fails + 1)) + fi +done +testmark_write doc +((fails == 0)) +``` + +What to copy from it: + +- **One case per name.** Hunks named `CASE/input` and `CASE/output` make a table; + adding a case is adding two blocks to the doc, with no change to the test. +- **Normalize, then check exactly.** Anything that changes from run to run, like a temp dir, gets replaced before the check (here, with `sed`), + so the check can stay exact. + Prefer this over grepping for a fragment of the output: + an exact check can't pass by accident, it shows the reader what the output really looks like, and regen can write it. +- **`< <(...)` into the check.** Not a pipe; see the rules below. +- **`testmark_write` at the end.** That's what makes `TESTMARK_REGEN=1` write anything. + With nothing to write, it does nothing, so call it unconditionally. + +A check doesn't see the tool's exit status. +When it matters, put it in the output: `< <(mytool || echo "exit: $?")` adds a line only when the tool fails. + + +Rules to know +------------- + +**State changes happen in the shell that parsed.** +`testmark_check`, `testmark_patch`, and `testmark_write` refuse to run in a subshell, where what they stage would be lost. +So feed a check with `testmark_check doc NAME < <(cmd)`, not `cmd | testmark_check doc NAME`; +and don't call these inside `$(...)`, or inside a test helper that runs its command there. + +**Output must be empty, or end in a newline, with no CRLF.** +A hunk's body can't hold anything else, so `testmark_check` rejects it outright, rather than failing forever. +Normalize it first. + +**A check never creates a hunk.** +Regen rewrites the body of a hunk that's there; a missing one is an error in both modes. +That's why the workflow starts with an empty block. + + +Functions +--------- + +| function | does | +| --- | --- | +| `testmark_parse HANDLE FILE` | parses FILE, and names it HANDLE for the other calls | +| `testmark_names HANDLE` | prints the hunk names, one per line, in document order | +| `testmark_body HANDLE NAME` | prints a hunk's body, exactly | +| `testmark_info HANDLE NAME` | prints a hunk's info string (`text` in ```` ```text ````) | +| `testmark_check HANDLE NAME <OUTPUT` | compares OUTPUT to the body: returns 1 and shows a diff on a mismatch; or under `TESTMARK_REGEN`, stages OUTPUT as the new body | +| `testmark_patch HANDLE NAME [INFO] <BODY` | stages a new body, and optionally a new info string | +| `testmark_write HANDLE` | writes what's staged into the file, in place | + +Errors come back as a nonzero status, with a `testmark: ...` line on stderr. +The doc comments in [`lib/testmark.bash`](lib/testmark.bash) are the full reference. + + +Requirements +------------ + +Bash 4.4 or later. +Older bashes lack things the library is built on: `local -`, for one, which lets it set shell options for itself and put yours back, arrived in 4.4. +[docs/hacking/bash-versions.md](docs/hacking/bash-versions.md) has more. +On an older bash, sourcing prints `testmark: needs bash 4.4 or later; this is 3.2.57(1)-release` and returns 1. +(That's macOS's `/bin/bash`. Use a newer bash from a package manager.) + +Nothing else, except `diff`, to show a mismatch. +The library doesn't depend on your shell options or locale; it sets its own on every call, and puts yours back. Contributing @@ -10,6 +152,8 @@ Run `make` to see all commands. Before committing anything, run `make all`. There should be no warning messages. +How the library works, and the bash pitfalls it steers around, are in [docs/hacking/](docs/hacking/). + License ------- diff --git a/examples/test-todos.sh b/examples/test-todos.sh new file mode 100755 index 0000000..f8f19b9 --- /dev/null +++ b/examples/test-todos.sh @@ -0,0 +1,26 @@ +#!/bin/bash +## Checks todos.sh against every case in todos.md. +## Run with TESTMARK_REGEN=1 to update todos.md to match what todos.sh does now; then review the change with `git diff`. +set -euo pipefail +cd "$(dirname "$0")" +source ../lib/testmark.bash + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +testmark_parse doc todos.md +mapfile -t names < <(testmark_names doc) +fails=0 +for name in "${names[@]}"; do + [[ $name == */input ]] || continue + case=${name%/input} + testmark_body doc "$case/input" >"$work/notes.txt" + if testmark_check doc "$case/output" < <(./todos.sh "$work/notes.txt" | sed "s|$work|WORK|g"); then + echo "PASS $case" + else + echo "FAIL $case" + fails=$((fails + 1)) + fi +done +testmark_write doc +((fails == 0)) diff --git a/examples/todos.md b/examples/todos.md new file mode 100644 index 0000000..2d04c3c --- /dev/null +++ b/examples/todos.md @@ -0,0 +1,59 @@ +todos.sh, by example +==================== + +`todos.sh` lists the lines of files that mention TODO, as `FILE:LINE:TEXT`. + +Each case below is a pair of hunks: +`CASE/input` is a file's content, and `CASE/output` is what `todos.sh` prints for that file. +`test-todos.sh` runs every case, with the file in a temporary directory whose path it replaces with `WORK`. + + +### the basics + +[testmark]:# (basic/input) +```bash +#!/bin/bash +# TODO: handle spaces in names +echo "$1" +# TODO: and newlines +``` + +Lines are numbered from one. + +[testmark]:# (basic/output) +```text +WORK/notes.txt:2:# TODO: handle spaces in names +WORK/notes.txt:4:# TODO: and newlines +``` + + +### lines come out as they are + +Leading whitespace and backslashes are kept. + +[testmark]:# (verbatim/input) +```text +notes: + TODO: indented with a tab + \TODO: with a backslash +``` + +[testmark]:# (verbatim/output) +```text +WORK/notes.txt:2: TODO: indented with a tab +WORK/notes.txt:3: \TODO: with a backslash +``` + + +### nothing to do + +No TODOs, no output: the output hunk is empty. + +[testmark]:# (none/input) +```text +all done here +``` + +[testmark]:# (none/output) +```text +``` diff --git a/examples/todos.sh b/examples/todos.sh new file mode 100755 index 0000000..ac0a8eb --- /dev/null +++ b/examples/todos.sh @@ -0,0 +1,12 @@ +#!/bin/bash +## todos.sh lists the lines of files that mention TODO, as "FILE:LINE:TEXT". +set -euo pipefail +for file in "$@"; do + n=0 + while IFS= read -r line; do + n=$((n + 1)) + if [[ $line == *TODO* ]]; then + printf '%s:%d:%s\n' "$file" "$n" "$line" + fi + done <"$file" +done