#!/usr/bin/env bash # `node --test web/tests/*.test.js` is a gate that stops gating the moment the # directory moves: bash leaves an unmatched glob as a literal, node 22+ treats # the literal as a glob of its own, matches nothing, reports 0 tests and exits # 0 (improvise-vz54). zsh aborts on an unmatched glob, so the failure is # invisible from a developer's shell and visible only under CI's. # # So the glob is the SCRIPT's, not the shell's -- quote it at the call site. # Three things are refused, and all three are "the gate asserted nothing" # wearing an exit 0: a pattern that matches no file, a run in which nothing # passed, and a matched file that registered no test. # # The last two are read off node's OWN report rather than scanned for lexically. # A `grep` for `test(` is satisfied by a commented-out call, by a string # mentioning one, by `test.skip`/`test.todo` and by an empty `test.describe` -- # every one of which executes no assertion (found by review on PR #69). # # Two measurements make the report usable, both on node 24.20, and both are # taken PER FILE. `# pass` is 0 for a skip-only file, a todo-only file and an # empty suite -- read over a whole run it would be masked by a passing # neighbour. And a file that registered NO test is reported as one passing test # WHOSE NAME IS THE FILE PATH, which is why `# tests` alone reads as the file # count and a minimum-count assertion over it is satisfied by the very file it # should reject. That naming convention is an assumption about node, so # scripts/node-test-test.sh pins it: if a future node stops doing it, the # self-test fails rather than the guard silently going quiet. set -uo pipefail usage() { echo "usage: ${0##*/} '' [''...]" >&2 echo " quote each glob: this script expands it, not the calling shell" >&2 exit 2 } [ "$#" -gt 0 ] || usage shopt -s nullglob files=() for pattern in "$@"; do matched=($pattern) if [ "${#matched[@]}" -eq 0 ]; then echo "node-test: no file matches '$pattern'" >&2 echo "node-test: refusing to run, because node would report success here" >&2 exit 1 fi files+=("${matched[@]}") done tap="$(mktemp)" trap 'rm -f "$tap"' EXIT # ONE FILE PER RUN. node's TAP does not attribute a passing test to the file it # came from -- three files produce three flat `ok` lines named after the tests # -- so a whole-run `# pass` count cannot tell a fully-skipped file from its # passing neighbours, and a skip-only file rode in on a sibling (found by # review on PR #69). node already forks a child per file, so this changes what # is OBSERVABLE, not how the tests run. status=0 total=0 silent=() barren=() for f in "${files[@]}"; do node --test \ --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=tap --test-reporter-destination="$tap" \ "$f" ran=$? if [ "$ran" -ne 0 ]; then status="$ran" continue fi passed="$(sed -n 's/^# pass \([0-9][0-9]*\)$/\1/p' "$tap" | tail -1)" if [ -z "$passed" ]; then echo "node-test: could not read a pass count out of node's TAP output for $f" >&2 status=1 continue fi total=$((total + passed)) if [ "$passed" -eq 0 ]; then silent+=("$f") continue fi # A file that registered NO test is reported as one passing test whose name # is the FILE PATH, which is why `# tests` reads as the file count. awk -v want="$f" ' /^(ok|not ok) [0-9]+ - / { desc = $0 sub(/^(ok|not ok) [0-9]+ - /, "", desc) sub(/ # (SKIP|TODO).*$/, "", desc) if (desc == want) { found = 1 } } END { exit found ? 0 : 1 } ' "$tap" && barren+=("$f") done if [ "${#silent[@]}" -gt 0 ]; then echo "node-test: these files passed nothing (skipped, todo or an empty suite):" >&2 printf ' %s\n' "${silent[@]}" >&2 fi if [ "${#barren[@]}" -gt 0 ]; then echo "node-test: these files registered no test, so running them asserted nothing:" >&2 printf ' %s\n' "${barren[@]}" >&2 fi if [ "$status" -ne 0 ]; then exit "$status" fi if [ "${#silent[@]}" -gt 0 ] || [ "${#barren[@]}" -gt 0 ]; then exit 1 fi echo "node-test: $total test(s) passed over ${#files[@]} file(s)"