Testing
Every script resolves against $_HI_HOME/say-hi, and the runner defaults _HI_HOME to this checkout’s parent, so a fresh clone needs no setup. With a second say-hi tree on the machine (an install beside a dev checkout), set it on every invocation: a shell that sourced an install exports the whole _HI_* set at paths that exist, and a suite run against the wrong tree reports fewer cases rather than failing.
export _HI_HOME=/path/to/parent-of-say-hi
tests/test_runner.sh
Contents
Running the tests
tests/test_runner.sh times each suite and prints a colored pass/fail summary:
tests/test_runner.sh # every suite
tests/test_runner.sh aliases shellcheck # just the named suite(s)
tests/test_runner.sh --group fast # what CI runs on every platform
tests/test_runner.sh --group fast,ci # ...plus the ci group, as ubuntu runs it
tests/test_runner.sh --group fast --shard 1/2 # half of it, as a CI shard runs
tests/test_runner.sh --host-report # ...prefixed with what this machine is
tests/test_runner.sh --verbose # every transcript, nothing collapsed
ciis the checks a second platform could only repeat - the workflows and manifests read as text, and the release tooling only Ubuntu runs (packaging_ci, a file of its own besidepackaging’s portable half, andtest_runner_ci,runner_test.sh’s ci part). Ubuntu’s fast job runs it;release.ymlruns it against the bumped manifests.- A passing suite’s transcript collapses to one status line; failures replay in full and are recapped under the summary table.
--verbose(_HI_VERBOSE=1) streams every transcript live, for a case that fails only under the runner. - A failed case is run once more under
set -x- once, never more. A second failure is FAILED, with the last lines of that trace under it: the comparison that failed, with its values, so a case with no message of its own still names its cause. A pass the second time is FLAKY, and a sign the case needs making sturdier. On Windows (WSL included), the BSDs, and arm64 (macOS included) it is not counted as a failure - it is listed under Flaky cases at the end of the run, a GitHub warning on CI. On native Linux x64, the least loaded platform, a flake is a failure like any other (_HI_FLAKY_OK=1/0overrides either way). Not for a failure that took over 20s, nor under the coverage sweeps (_HI_TRACE_RERUN=0). - Suites running side by side replay only once the last one finishes, so a progress line fills the wait (finished suites, cases, failures, elapsed, what is still running): redrawn in place at a terminal, a line per finished suite plus a 30s heartbeat on CI.
_HI_PROGRESS=0/1overrides. - A suite whose backend is missing reports SKIPPED, never green; so does a single case (an image that would not build, a tool not installed).
--require-run(CI’s lint, e2e, and backends jobs pass it) turns both into failures: the suite goes red, its transcript replays, and each stood-down case is named in the recap. --host-report(_HI_HOST_REPORT=1) prints one block before the first suite: bash, OS, CPU and memory, userland (GNU/BSD/busybox), the locale’s glyph verdict, which tree$_HI_HOMEresolves to, which backends answer, and tool versions. Every CI job but the lint runs andrelease.yml’scirun passes it. The tree half prints on every run when the tree under test is not the one the runner was invoked from — the quietest way to get a wrong result here.- Every test script also runs directly, e.g.
tests/lint/shellcheck_test.sh. - Under a wrapper that leaves a controlling terminal but a background process group (
timeoutfrom a prompt, awsl.exesession), runsetsid -w timeout … tests/test_runner.sh …—setsidoutside, sotimeoutstill kills the runner’s group. Without it the interactive sessions theloadandinstall_locationsuites start stop on SIGTTIN until the deadline. GitHub’s ubuntu runner has no controlling terminal. - Under WSL 2, drop the
/mnt/entries from$PATHfirst: interop’s ~50/mnt/c/...directories are served over 9p, and the package check’s uncachedcommand -vmisses make onefull_checktake 30–37s instead of 30ms - half a minute a check, when the pty cases’ deadline was 30s.windows-e2e.yml’swsl-suitesjob does this and has the measurements.
Six groups (--group <name>, or a comma list; --list prints the membership) - ci above, and:
fast— dependency-free unit suites, the first thing every CI platform job runs;test_lib,test_lib_report,test_lib_par, andtest_runnerare the harness testing itself. Suites run side by side (one per CPU), each with its own workdir and tally files, transcripts replayed in table order, so the run reads like a serial one and takes about as long as its slowest suite._HI_RUNNER_WIDTH=1runs one at a time, as--verbosedoes.--shard <i>/<n>keeps every n-th suite of the selection from the i-th on, to split a group across runners:windows-client.ymlrunsfastas four slices (1/4 to 4/4) on each of x64 and arm64, eight jobs rolled into onefast suites (Git Bash)check (backgrounded suites barely overlap under MSYS, so only more machines shorten it), andci.ymlshardse2eandbackends.lint— the lint gate: its own ubuntu CI job, besidefast’s; the macOS, Windows, and BSD jobs runfastalone, since linting text does not depend on the userland. Runnpm ci --prefix .githubfirst, or its two Markdown checks skip.bench— hot-path timings against ceilings, plus the payload’s two size budgets.e2e—ssh,ssh_disconnect,ssh_relay,ssh_wire,install_methods,repo,docker,framework,home_prompt,ssh_keep: throwaway containers drivinghi.sh’s real connection paths (_say_hiand_say_hi_container).ssh_wirecounts one session’s bytes through aProxyCommandand checks them against the figure hi prints on its connect line.ssh_keepholds a kept session in tmux, in screen, and in zellij (alpine’s package) through a dropped link and a reattach,hi --end, its timeout, ahi --keeptyped inside a session, a second pane, a session killed outright, and a link cut inside a local multiplexer with the session left or lost, reading each answer off the target rather than the multiplexer’s redrawn transcript. Two are not about sessions:repobuilds the package repository with throwaway keys and installs from it as apt, dnf, and apk clients, signatures verified, andhome_promptinstalls hi over each distro’s stock/etc/skelrc to check which prompt wins at home.backends—podman,nomad,kube: split frome2ebecause they need extra runner setup.
bench, e2e, and backends always run one suite at a time: the bench measures, and the other two contend on one container daemon.
A case stands down through two guards: _hi_check_requires <bin> when a command is missing, _hi_check_capable <capability> when a facility is — something command -v cannot answer (_hi_par_check_capable is the parallel twin for a fast case; an e2e case asks _hi_capable directly and skips inline). The roster is _hi_capable in tests/lib/fixtures.sh:
| Capability | How it answers | Why it can be no |
|---|---|---|
symlink | probe: makes one, tests [ -L ] | a filesystem that refuses or silently copies reads as no |
pty | probe: python3 can import pty | no pty layer to drive an interactive case with |
mkdir_mode | probe: a mkdir -m whose mode lands | a Windows-owned temp tree makes the directory and refuses the chmod |
gpg_agent | probe: an agent in a throwaway homedir | Git for Windows ships gpg with no agent it can start |
lockout | probe: writes into a chmod 555 dir | root bypasses the bits, so the write succeeds |
fork_concurrency | uname (MSYS/Cygwin) | background subshells have to genuinely overlap |
mode_bits | uname (MSYS/Cygwin) | a reported permission string has to reflect chmod’s own bits |
netem | /proc/modules or /sys/module | modprobe sch_netem has not run on the host; a container can’t |
The starved case in the ssh e2e suite is the one that asks for netem: it shapes a container’s own link with tc qdisc … netem, which needs the module loaded on the host kernel, not just present in it. ci.yml’s e2e job loads it in its setup step, so the capability gate mainly matters on a local or dev machine that hasn’t modprobe‘d it.
Where a suite lives
tests/<the directory it tests>/. tests/common/, tests/config/, tests/scripts/, and tests/packaging/ mirror the tree; tests/hi/ and tests/load/ cover the two root scripts; tests/lint/ is the lint gate, tests/bench/ the timings, tests/targets/ the container/ssh e2e suites, and tests/harness/ the suites that test the harness. The harness itself is tests/test_lib.sh, a façade over tests/lib/; a suite sources the façade and nothing else (HI.34).
The container suites run their cases in parallel
ssh, ssh_relay, ssh_keep, install_methods, docker, podman, framework, and kube spend nearly all their wall clock waiting on containers, so their cases run in batches: _hi_par_case (tests/lib/parallel.sh) submits a case to a background subshell, _hi_par_wait collects the batch. Each case writes its verdict to a file the parent tallies, registers what it started on a teardown ledger the exit trap sweeps, and buffers its output to replay in submission order. Cases that read another case’s files stay serial.
A batch is capped at four, or the CPU count if smaller — unbounded fan-out thrashes the docker daemon on a laptop. A suite whose cases are plain local processes (configure’s pty runs, install_location’s shells, the harness suites’ nested runners) sets _HI_PAR_LOCAL=1 and gets the whole CPU count. _HI_PAR_WIDTH overrides both; nomad pins itself to 1, since its single-node dev agent gains nothing from two cases at once.
_HI_PAR_WIDTH=1 tests/test_runner.sh ssh # serial, same code path - for bisecting a flake
_HI_PAR_WIDTH=8 tests/test_runner.sh ssh # a big machine, if the daemon can take it
The pty-driven cases (configure, install, rc_lines) kill their child after 60s and count it a failure; _HI_CASE_TIMEOUT raises that deadline on a host slow for reasons the suites cannot fix, as _HI_SSH_CASE_TIMEOUT (90s) does for the ssh cases. The login shells _hi_login_env starts (install_location’s dialect pass) are bounded the same way at 180s by _HI_LOGIN_TIMEOUT: unbounded, one that wedges shows only as a case count that stops moving, for as long as the job allows.
The install-method suite
install_methods is ssh’s sibling: ssh runs one install against every login shell; install_methods runs one login shell against every way say-hi gets onto a machine — the .deb, .rpm, .apk, a Homebrew-shaped keg, a system-wide install.sh --prefix, and a packaged tree with its /etc/profile.d announcement removed. They share the case runner in tests/lib/ssh.sh.
hi ships its payload to every ssh target and never reads a say-hi already there, so every case asserts the same two things: the session runs out of its own tree ($_HI_ROOT is not the installed path), and the installed tree is still whole once the session is gone.
The three package cases build what they install with packaging/mkpkg.sh, so they need nfpm; without it they stand down yellow per case, which --require-run catches. ci.yml’s e2e job installs the pinned nfpm so they run.
Coverage and profiling
Two coverage tools, kcov (tests/coverage.sh) and bashcov (tests/coverage_v2.sh), each run over the full suite sweep — every suite the box’s backends can host — by hand or by coverage.yml: after every green CI run on a push to main, and on every same-repo, non-draft PR, where the comment job posts both figures beside main’s in one comment edited in place, with the PR’s passed-case count from its CI run beside main’s (the push that merges the PR reuses them). The two aggregates have tracked each other within a few points for many commits: the average of the two badges is the coverage figure, and the per-file reports are for finding untested arms. Only a divergence of tens of points means one tool has lost the plot. Never a gate: the pull request template’s 90% is a target, and the PR comment flags an average below it without failing anything. Both sweeps pin _HI_PAR_WIDTH=1, since a batch writing into a suite’s one trace stream side by side loses lines, and both put a bash-as-sh first on their own PATH: neither tracer follows a dash child, so a #!/bin/sh file a suite executes as sh … (common/targets.sh) would read 0% where /bin/sh is dash.
In CI each tool is sharded 4 ways (--shard i/4, which _hi_cov_select_suites passes straight to test_runner.sh) and merged by a gather job: kcov --merge over the shards’ parts/ directories, and a plain JSON hash union of bashcov’s --command-name-keyed .resultset.json files, safe because shards partition the suite table.
- kcov’s known failure mode is losing its DEBUG trap once the harness is sourced, which reads whole suites as load-time-only; the script’s header keeps the probe to re-run if the badges diverge.
kcov --mergere-reads the sources at the absolute paths each shard recorded, so a merge with no working tree yields"files": []and a well-formed0.00%badge, not an error.coverage.yml’sgather-kcovchecks the tree out before callingtests/coverage.sh --merge(a local sweep’s own merge, ranking, and all-zero refusal), and thetest_runner_cisuite (runner_test.sh’s ci part) asserts every job calling it does.gather-bashcovneeds no checkout. - bashcov reads bash’s
xtrace, and skews the other way: a heredoc body counts as covered whether or not it ran, and children underenv -ior inside containers drop out of the trace — so a single file reads a few points off either way while the pair brackets the truth. More readings are artifacts, not gaps, andtests/coverage_v2.sh’s header is their list: a scratch-tree copy, anevalinside a$( ), a zsh-only arm, thesh …case above, lines a passing case asserts but that still read 0, and the linesxtracenever prints (an empty case arm, a redirect line). Rule those out before writing a test against a number. It needsgem install --user-install bashcov(found off the gem bin directory if not on$PATH), and writes a.simplecovinto the checkout for the run, refusing to start rather than overwrite one you have.
pages.yml publishes both aggregates as shields endpoints (badges/coverage.json, badges/coverage-v2.json), each labelled by its tool and computed over the shipped product only (tests/ and docs/ excluded). A completed Coverage run is pages.yml’s only automatic trigger, so a badge is never older than its sweep.
common/zsh.zsh and common/config.fish sit outside both tools: kcov’s DEBUG trap and bashcov’s xtrace are bash mechanisms, so no line-coverage figure for them is honest. What stands in: tests/lint/dialects_test.sh’s floor checks (checks 2–5), and tests/common/exports_test.sh and tests/hi/prompt_test.sh, which drift-check common/config.fish against common/core.sh line by line.
tests/profile.sh is for a tripped --group bench ceiling: _hi_bench says whether a path got slower, this says which command in it did. It profiles the four bash paths the bench guards through timep, in a container (tests/dockerfiles/timep.Dockerfile): timep enable -fs base64-encoded loadable-builtin .so files into the running shell, which is worth sandboxing, and the box settles three requirements timep does not check — glibc ≥ 2.38, a bash with enable -f, and an exec-capable /dev/shm. Missing any, timep exits 0 and writes arithmetic errors instead of times, so profile.sh grades the output, not the status. The checkout is mounted read-only; $_HI_TIMEP mounts a local copy of timep you have read. Read the ranking, not the milliseconds. ci.yml’s profile job runs it beside bench and uploads the profiles artifact (14 days); it is advisory (continue-on-error), the bench ceilings stay the gate.
Timing and shared state
The Windows and BSD runners are where a timing assumption shows up, so the harness makes these choices on purpose:
- Isolation per suite.
test_lib.shgives each suite a freshmktemp -droot holding itsXDG_CONFIG_HOME(absent until a case makes it),XDG_RUNTIME_DIR, andHOME, so parallel suites never share hi’s payload, overlay, or ssh-tags caches, nor a developer’s real sockets, and no dotfile of a developer’s rides a stream a suite builds. docker keeps its contexts throughDOCKER_CONFIGand podman its storage throughXDG_DATA_HOME, both set from the home the run started in;_HI_TEST_KEEP_HOME=1leavesHOMEalone, for a toolchain that resolves through it. Cleanup removes that root by its recorded path - never whatever a variable points at by then. - Pinned caps.
_HI_PROBE_TIMEOUTis 10 in the suites (a user’s 2s is for real CLIs; the suites probe shell shims); the pty cases’ cap is 60s,_hi_login_env’s 180s. A cap bounds a wedge, never a pace. - Meetings over sleeps. A case proving concurrency has its shims wait for each other (bounded) rather than sleep a fixed time and hope.
- The pty rig’s verdict is a file. Each child writes its verdict line to
<label>/verdictas well as the pty; a BSD pty can drop a fast child’s last output.
Written down as not races:
kill -0as “still running”._hi_wait_pidand_hi_par_slotpoll it, and a pid bash has reaped can be reused. The job table would say better, but only through$(jobs), and whether a command substitution sees the parent’s jobs varies: where it did not (Git Bash, OpenBSD), the timeout stopped counting and a hung case hung its shard. So the poll stays; a reused pid needs the old one to exit and a stranger to take its number inside one poll’s window.- Wall-clock deadlines.
$SECONDSis the wall clock, chosen over counted iterations (process.sh says why); a VM whose clock NTP steps mid-run can shorten one, a trade taken knowingly. - One
HOMEfor the parallel fresh-shell cases ininstall_location_test.sh: zsh’s compdump, fish’s variables, and shell history each write through their own lock or append, and no case reads another’s. - Build-once fixture helpers (
_hi_fake_path,_hi_real_path,_hi_git_fixture,_hi_stub_tools,_hi_can_mkdir_mode): safe while their callers are serial, as every caller is; build one before a_hi_par_begin, never inside a parallel case.
Instrumented, not yet explained: on Windows arm64, hi_payload’s include scan cases have left an empty overlay stream with no error of their own (gzip then reports “unexpected end of file”). The suite wraps _hi_overlay_tar to print its exit status and stderr, so the next one names its cause.
The images are files; the build contexts are not
Every image a suite builds is a real Dockerfile under tests/dockerfiles/: sshd-debian, sshd-alpine, sshd-fedora, and sshd-bash32 for the ssh targets, alpine-shell for the bare shell ones, installed-* for the pre-installed targets (installed-pkg takes the .deb/.rpm/.apk as a build arg), framework for the thirteen shell frameworks and tools (the framework a build arg naming a script under frameworks/), apt-client for the repo suite’s apt subscriber (openssh-client preinstalled, no Ubuntu archive in its sources, so the cases fetch from the repository under test alone), and fish34, fish4, zsh55, and timep for the lint gate and the profiler. Only the build context is generated per case: the entrypoint.sh, and for the pre-installed cases the repo itself. Suites reach a file through _hi_dockerfile <stem>; variants differing only by a package list or base image are one file plus a --build-arg (PKGS, BASE). docs/tapes/fixtures.sh builds its demo-* images from the same folder, spelling the path out, since a tape render does not source test_lib.sh. The lint gate checks both directions: no Dockerfile without a caller, no caller naming a missing Dockerfile.
What is pinned, and what deliberately is not
Upstream base images are digest-pinned, non-negotiably. Every FROM in tests/dockerfiles/ that names an upstream image (11 of 20: alpine, ubuntu, debian, fedora, bash, zshusers/zsh) carries a @sha256: — what makes a failed e2e run reproducible and a base-image move a deliberate, reviewable act. Dependabot bumps the digests weekly, which is how a base image reaching EOL gets noticed. The other 9 are FROM ${BASE} over an image a suite or a tape builds locally (hi-test-sshd and the like; see --build-arg BASE= in tests/targets/*_test.sh), with no upstream digest to pin; Scorecard flags them as unpinned containerImage dependencies, and .scorecard.yml annotates the check test-data rather than pretending it’s clean. Nothing in tests/dockerfiles/ reaches a release; the release path’s workflows and actions are SHA-pinned separately.
The same tags named in shell and YAML are guarded, not watched. alpine: and debian: also appear as plain tags in tests/lib/backend.sh, docs/tapes/fixtures.sh, and ci.yml’s packaging smoke, where Dependabot cannot see them. lint_image_tags fails when a tag named anywhere in the tree disagrees with the digest-pinned ones; lint_image_digests when two Dockerfiles pin one tag to different digests.
Every framework fetch is pinned. Six to a release and a hash, each downloaded to a file and sha256sum -cd before use: frameworks/plgo.sh’s release binary (v1.26), frameworks/tmux.sh’s fzf release (v0.74.4, since bookworm’s apt fzf predates $FZF_DEFAULT_OPTS_FILE), frameworks/tide.sh’s fisher (4.4.5, installing tide v6.2.0), and the atuin, mise, and starship installers. atuin.sh (v18.20.1) and mise.sh (v2026.8.14) name the release tag in the download URL, so hash and version move together (mise.run is regenerated per release whatever MISE_VERSION says, so pinning that alone drifts); starship.sh (v1.26.0, via --version) fetches the generic starship.rs/install.sh, so its hash pins that day’s copy of the installer — a mismatch needs a fresh hash, not a version bump. The frameworks without releases are pinned by commit: the oh-my-zsh and oh-my-bash installers by commit and hash, each fed its framework through a local mirror fetched at a commit (omz.sh, p10k.sh, omb.sh), the bash-it and powerlevel10k checkouts by commit, and atuin’s bash-preexec by commit and hash. Every pin is named in its script and bumped by hand. The image builds under pipefail, so a 404, a checksum mismatch, or an installer changing shape fails the build rather than shipping an image with the framework missing.
The lint gate
--group lint is four suites, twenty-eight checks between them. Each suite is its own process (shellcheck, dialects, tools, drift) with its own tally in the summary table, so a failure in one never hides what the others found.
CI’s lint suites job passes --require-run, so a check that skips yellow locally for want of a tool fails there. Its binaries are pinned in .github/actions/setup-tool/tools.txt: eight columns, name|pin|kind|url|verify|check|tag-prefix|sha256 (the file’s header documents each), the last a sha256 of the download, one for every platform or one per platform (linux-x86_64=<hex>,darwin-aarch64=<hex>), so a pin moves only with its checksum. tool-versions.yml drift-checks every row weekly, and .github/scripts/check_tool_versions.local.sh adds this repo’s inline pins and image checks. markdownlint-cli2 and prettier are pinned in .github/package-lock.json instead, installed by npm ci --prefix .github and run from .github/node_modules/.bin. After the group, the same job runs lychee (settings in lychee.toml) offline over the tracked Markdown, so a broken relative link or #fragment fails the PR that makes it; link-check.yml’s weekly sweep checks the external URLs.
shellcheck (tests/lint/shellcheck_test.sh) — one check, and most of the group’s cost. A fatal guard runs before it: every source of a $_HI_CONFIG_DIR/... path must carry a # shellcheck source= directive, or .shellcheckrc’s source-path=SCRIPTDIR under shellcheck -x resolves a bare basename to the sourcing file itself and re-parses it until the kernel OOM-kills it (~33GB resident).
- 1. shellcheck over every
*.sh, one invocation per CPU, replayed in order (_HI_SC_WIDTH=1for one at a time).
dialects (tests/lint/dialects_test.sh) — four shell-dialect checks. 3–5 skip yellow without docker, but with docker present a base image that won’t build is a failure: it’s digest-pinned, so “won’t build” means the image no longer carries the version the check claims.
- 2. Native syntax checks:
zsh -n/fish --no-executeover the files those shells parse for themselves, with whateverzshandfishthis machine has. - 3. The fish 3.4 floor: the same files in a pinned fish 3.4.1 (
tests/dockerfiles/fish34.Dockerfile, oh-my-fish’s prebuilt image), since newer fish accepts constructs older fish rejects. The one it caught: a{ ... }group incommon/paths.sh, a brace expansion before fish 4, which refuses a comment inside one with “Mismatched braces” and fails even a clean one at run time. Sopaths.shis also sourced there with_HI_DISABLE_LOCAL=1, and its gate is one guarded line per toggle. - 4. The fish 4 ceiling: the same files in a pinned fish 4 (
tests/dockerfiles/fish4.Dockerfile, Ubuntu 26.04’s), for a construct fish 3 accepts that fish 4 rejects. - 5. The zsh 5.5 floor sources
common/zsh.zshin a real interactive zsh 5.5.1 (tests/dockerfiles/zsh55.Dockerfile) and asks for a prompt, the aliases, a resolved host color, and the prompt separator — zsh’s risky constructs parse everywhere and only misbehave on an old zsh. 5.5.1 because it is RHEL 8’s, while bookworm, noble, alpine, and macOS all ship 5.9; the image is upstream’szshusers/zsh:5.5.1, since a distro apt install failed on hosted runners only.
tools (tests/lint/tools_test.sh) — seven external-tool wrappers, each skipping yellow when its tool isn’t installed (CI has all seven):
- 6. shfmt over the same
*.shlist, style from.editorconfig. Fix a red run withshfmt -won the paths it names, notshfmt -w ., which also reformatscommon/zsh.zsh(zsh, ships as-is). - 7. checkbashisms over the
#!/bin/shfiles, which dash and busybox sh really do parse on minimal targets. - 8. mandoc over
docs/hi.1(mandoc -T lint -W warning). - 9. The editors demo’s vimrc (
docs/tapes/editors/vimrc), loaded undervim -u … -esthe way the alias does, so a syntax error shows here rather than on the recording. - 10. typos over the whole tree, allowlisted by
.typos.toml. - 11. markdownlint (markdownlint-cli2, rules in
.markdownlint.yaml) over the Markdown git knows about, tracked or new. - 12. prettier –check over the same list, style in
.prettierrc.yaml, skipping.prettierignore’s files, with.github/prettier-plugin-docs.mjs, which writes each doc’s## Contentslist from its##and###headings, SETTINGS.md’s table fromscripts/settings, and USAGE.md’s commands fromcommon/flagsanddocs/tapes/usage: a stale one fails like any other formatting, and a flag with no usage row fails outright. Fix withprettier --write --plugin ./.github/prettier-plugin-docs.mjson the paths it names.
drift (tests/lint/drift_test.sh) — sixteen repo-consistency sweeps, each checking that something written down elsewhere still agrees with the tree:
- 13. The bash-3.2 grep: no
mapfile, associative arrays, namerefs,${x,,},wait -n, or${!a[@]+…}— each explained in GLOSSARY.md by itsGLOSSARY: HI.NNtag. - 14. One-userland spellings: SYNTAX.md’s enforced rows -
echo -e,sed -rand-i,grep -P,readlink -f,xargs -r,head -n -N,date %-X, gawk-only functions,mktemp -twith no X’s, and basic-sed alternation. - 15. Paired spellings:
stat -conly beside itsstat -ftwin, and strict mode switched off again in every file an interactive shell sources (HI.15). - 16. The
$HOMEdefault sweep: nothing may fall back to$HOMEwhen it derives the say-hi tree — over*.zsh,*.fish, and*.mdtoo, since the docs teach the rule as much as the code obeys it. - 17. Ignored payload: no file the payload or a package ships is one
.gitignoreswallows, asked of git itself — the suites read the working tree, so nothing else would notice it never reached a commit. - 18. GLOSSARY tags: every
GLOSSARY: HI.NNin the tree names a code GLOSSARY.md defines, and every entry is referenced; matched by code, not title. - 19. The settings rows: every row of
scripts/settings(the rows SETTINGS.md’s Every setting table is written from andhi --configurereads its items from) names a variable the shipped tree still reads, and none is one the doc’s Not settings lists. - 20. The docker-compatible family:
common/core.sh’s$_HI_CONTAINER_CLISandcommon/targets.sh’s copy name the same CLIs (HI.51), since neither file can read the other. - 21. The runtime directory:
common/core.sh’s_hi_runtime_dirandcommon/targets.sh’s cache directory build the same path, with the same ownership guards, in their two dialects. - 22. Liquid syntax: no page the Pages build renders may carry a raw Liquid delimiter outside a guarded span — Liquid tokenizes before Markdown, so a fence gives no shelter.
- 23. Site links: a relative link on a page the site builds lands on a page the site builds too, not a dot-path or anything
_config.ymlexcludes (which renders on GitHub and 404s on Pages); link those as absolute github.com URLs. - 24. The tldr page: every
hi --flagexample indocs/tldr.mdnames acommon/flagsrow, and there are at most eight examples (the upstream cap). - 25. tests/dockerfiles/: every image definition has a caller and vice versa.
- 26. Image tags: every plain image tag named in shell or YAML is one of the digest-pinned
FROMtags intests/dockerfiles/. - 27. Image digests: two Dockerfiles pinning the same
image:tagagree on its digest — check 26 strips digests before comparing, so it can’t see one tag pinned two ways. - 28. The eval roster: every
evalin the payload andscripts/has a row intests/lint/eval_rostersaying what it evaluates, and every file’s count of its kin (asourceof a path in a variable, a shell’s-c, a recursiverm) matches its row; a row nothing matches fails too, so the roster only counts down.
Test levers
Five environment variables the tree reads that are not settings: they exist so a suite, the bench, or a demo can pin what a real run derives. _HI_TARGETS_TTL (seconds targets.sh reuses its list; 0 sweeps every call), _HI_PROBE_TIMEOUT (seconds one backend CLI gets), _HI_PAYLOAD_CACHE (0 builds the payload and overlay archives fresh), _HI_CTL_PERSIST (seconds a shared ssh control socket lingers; 0 gives a connect a private one, closed after) and _HI_HEADER_VERSION (the header’s version cell, otherwise the release stamp or git describe — what docs/tapes/generate.sh --version sets). Their defaults are the shipped behaviour; hi --configure writes none of them. _HI_LOAD_NO_INIT=1 is adjacent but not a lever: it makes load.sh define its functions without running the profile chain, for scripts/install.sh to source.
Relaying
hi chains: from a session on B you can hi C, and the second hop is a full hi session — from a disposable session too, because hi.sh is a member of $_HI_PAYLOAD and arrives with its exec bit (hence the cat write-back, HI.09). ssh_relay is the proof: A → B → C, config intact on the final hop, cleanup traps firing on both B and C, on a clean exit and on the link being killed mid-relay. The one tier that cannot relay is the container transport’s bash-less fallback, which ships aliases.sh alone and never loads paths.sh — there hi is not defined.
Local-only
The payload carries no scripts/, tests/, or .git, so on a target every local command (hi --doctor, --preview, --install, …) says it needs the full checkout rather than running, and --update says so in a package install too, which ships scripts/ but no .git. Which flag needs what is common/flags’ <needs> column; docs/hi.1 is the long form, drift-checked against the flags by tests/hi/parse_test.sh.
Why the harness is hand-rolled
bats-core, shellspec, and a python/pytest driver were weighed as replacements for tests/test_lib.sh and tests/lib/*.sh (a paper study: no suite was ported, no timings). test_runner.sh stays either way, so a candidate has to hand it something it can still collect through $_HI_COUNTS_FILE/$_HI_FAILS_FILE. Scored on that basis:
| this harness | bats-core | shellspec | a pytest driver | |
|---|---|---|---|---|
| macOS bash 3.2 | native (it’s what’s shipped) | claimed, “Bash 3.2 or above” | bash 3.2.57 named and CI-tested | n/a - drives shells, isn’t one |
| Git Bash, install step | none - it’s the tree | source-only; no MSYS2/Cygwin/WSL tier documented | Git Bash, msys2, cygwin, busybox-w32, WSL all tested | import pty fails - Unix only, stdlib says so |
| runtime-generated cases | plain bash for, unrestricted | bats_test_function (workaround; order is issue #860) | Parameters:dynamic allows a for loop, but no function/variable access | parametrize/pytest_generate_tests, fully dynamic |
skip vs. --require-run | three tiers, wrapper turns every skip into a fail | skip; no fail-mode documented | Skip/Pending; no fail-mode documented | needs pytest-error-for-skips, a second package |
| parallel, ordered replay | own scheduler, submission-order replay | needs GNU parallel; order not guaranteed | own --jobs; ordering undocumented | needs pytest-xdist; reorders for scheduling |
| coverage topology | suite = top-level process, kcov + bashcov merged | kcov known to read 0% on some .bats files (kcov#462) | built-in --kcov, same bash/zsh/ksh DEBUG-trap limit as ours | coverage.py measures Python only - subprocess bash is invisible to it |
| lint-gate reach | already *.sh; shellcheck, shfmt, checkbashisms all apply | shellcheck parses .bats since 0.7; shfmt/checkbashisms don’t know the extension | DSL keywords (It/When/Then); no shellcheck dialect found | not shell at all - opts the driver out of the gate entirely |
| new dependency | none | GNU parallel | none found required | pytest + xdist + a pty replacement on Windows |
Keep it. Every candidate loses at least one row, usually more, and the sweep’s wall clock is container boot and e2e deadlines rather than per-case dispatch, so speed would not have decided it. Reopen it if Git Bash leaves the platform matrix, both coverage tools go unmaintained at once, or one of the three documents an ordered parallel mode and a skip-as-failure flag without a second package.