Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions tests/add-app-errors.bats
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# covers the failure paths task 8 asked for beyond the brief's five given
# tests: locating the project (outside git, inside a non-scaffold git repo),
# a pre-existing target surviving a refused add with its contents intact,
# resolving the target relative to the project root from a subdirectory, and
# the cleanup trap actually firing on a partial failure — all without a real
# adapter's network-hitting generator, so this file stays fast.
# Failure paths for `scaffold add`: locating the project (outside git, inside
# a non-scaffold git repo), a pre-existing target surviving a refused add with
# its contents intact, resolving the target relative to the project root from
# a subdirectory, and the cleanup trap actually firing on a partial failure —
# all without a real adapter's network-hitting generator, so this file stays
# fast.

setup() {
load 'helpers/setup'
Expand Down
16 changes: 6 additions & 10 deletions tests/compose.bats
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,10 @@ teardown() {
@test "compose.yaml, install.sh and the build workflows name one registry path" {
# build.yml pushes the image compose.yaml pulls, and install.sh downloads
# the release that publishes compose.yaml. Three files, one path — written
# from the same owner and project name at generation time.
#
# They used to disagree by construction: the workflows were substituted and
# compose.yaml/install.sh shipped `CHANGEME/CHANGEME`, so a project's first
# release named an image nothing had pushed and needed a hand-edit plus a
# second release before install.sh worked at all.
# One entry per application now (ADR-0022), so this asks the question of
# every one of them rather than of a single `app` service.
# from the same owner and project name at generation time; letting them
# drift leaves install.sh downloading a release nothing pushed. One entry
# per application (ADR-0022), so this asks the question of every one of
# them rather than of a single `app` service.
local images want_repo
images="$(yq -r '[.jobs[] | select(has("with")) | .with.images] | .[0]' \
"${PROJECT}/.github/workflows/build.yml")"
Expand Down Expand Up @@ -162,8 +158,8 @@ INNER_EOF

@test "install.sh survives being piped into bash instead of dying on an unbound variable" {
# documented as curl-piped (`curl ... | bash`), same as the immich script
# this is adapted from — piped in, BASH_SOURCE[0] is unbound, and
# `set -o nounset` used to kill the script before the source guard even ran.
# this is adapted from — piped in, BASH_SOURCE[0] is unbound, which
# `set -o nounset` would otherwise kill before the source guard even runs.
mkdir -p "${WORKDIR}/piped"
cd "${WORKDIR}/piped"
run bash <"${PROJECT}/install.sh"
Expand Down
32 changes: 11 additions & 21 deletions tests/contract.bats
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ setup() {

@test "test-unit's suites never run an adapter generator to completion" {
# mise.toml's test-unit membership is an explicit file list backed by a
# comment claiming "no adapter generator anywhere in setup" — the same
# "synced by a comment, drifts silently" shape task 13's review round 1
# existed to fix elsewhere. this greps the claim instead of trusting it.
# comment claiming "no adapter generator anywhere in setup" — a claim that
# drifts silently if trusted, so this greps it instead.
local run_line files f
run_line="$(awk '/^\[tasks\."test-unit"\]/{f=1} f && /^run = /{print; exit}' "${SCAFFOLD_ROOT}/mise.toml")"
files="$(grep -oE 'tests/[A-Za-z0-9_-]+\.bats' <<<"$run_line")"
Expand Down Expand Up @@ -104,8 +103,8 @@ setup() {
# The missing-driver fixture above proves a family with no driver file
# fails; nothing proved the mirror case — a driver file that exists and
# sources cleanly but omits one of REQUIRED_DRIVER_FUNCTIONS. Deleting the
# whole `for fn` loop in lint_services left this suite green, which is the
# same "gate that cannot fail" shape task 1's own ruling already named.
# whole `for fn` loop in lint_services would leave this suite green too: a
# gate that cannot fail either way.
run lint_services \
"${SCAFFOLD_ROOT}/tests/fixtures/lint-services/missing-driver-function" \
"${SCAFFOLD_ROOT}/adapters"
Expand Down Expand Up @@ -185,12 +184,9 @@ setup() {
}

@test "every suite runs somewhere" {
# A suite in no lane is a suite that never runs: the service branch shipped
# thirty tests into that state, and nobody noticed until a review read
# mise.toml against ci.yml. Three tests used to guard this, one per suite,
# each added after the next suite fell through the same gap — a list that
# only ever grows by being wrong first. This asks the question of every
# suite in the directory instead, including the ones not written yet.
# A suite in no lane is a suite that never runs, so this checks every suite
# file in the directory against mise.toml directly rather than keeping a
# hardcoded list that could itself go stale.
#
# Scoped to each lane's own `run = ` line, not the whole file: a name greps
# clean out of a comment, or out of [tasks.test]'s "tests/" glob, which CI
Expand Down Expand Up @@ -270,16 +266,10 @@ setup() {

@test "every caller of scaffold new supplies the environment it demands" {
# scaffold new refuses to run without a GitHub owner, because the workflows
# it generates carry a `you/` placeholder that has to be substituted. Two
# things in this repository call it outside the test suite:
#
# scripts/deploy-check.sh exports its own
# .github/workflows/adapters.yml sets it on the step
#
# The workflow was the one that did not, and it failed every scheduled run
# from 2026-09-05 to 2026-09-08 — the second time a caller was missed after
# the same defect was fixed for deploy-check.sh alone. Grepping the call
# sites is what makes a third one impossible to miss.
# it generates carry a `you/` placeholder that has to be substituted, and
# every caller outside the test suite must export it. Grepping the call
# sites, rather than checking a hardcoded list of them, is what catches a
# caller that forgets to.
#
# tests/*.bats are excluded deliberately: they reach scaffold through
# tests/helpers/setup.bash, which exports the variable once for all of them,
Expand Down
61 changes: 20 additions & 41 deletions tests/helpers/setup.bash
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# Shared bats setup: an owned environment, assert_ok, copy_toolbox.
# shellcheck shell=bash
# ═══════════════════════════════════════════════════════════════════════════
# Script : tests/helpers/setup.bash
# Description : Shared bats setup: an owned environment, assert_ok, copy_toolbox.
# Author : ttncode
# ═══════════════════════════════════════════════════════════════════════════
SCAFFOLD_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export SCAFFOLD_ROOT
PATH="${SCAFFOLD_ROOT}:${PATH}"
Expand All @@ -13,29 +9,22 @@ export PATH
# a project fails resolve_github_owner's guard.
export SCAFFOLD_GITHUB_OWNER="${SCAFFOLD_GITHUB_OWNER:-test-owner}"

# Same shape: a runner has no git identity either, and `scaffold new` commits
# what it creates. Owned by this suite rather than written into the runner's
# HOME.
if [ -z "${GIT_CONFIG_GLOBAL:-}" ]; then
# A runner has no git identity either, and `scaffold new` commits what it
# creates. Owned by this suite rather than written into the runner's HOME.
if [[ -z "${GIT_CONFIG_GLOBAL:-}" ]]; then
GIT_CONFIG_GLOBAL="${BATS_TEST_TMPDIR:-${BATS_SUITE_TMPDIR:-/tmp}}/gitconfig"
export GIT_CONFIG_GLOBAL
git config --global user.name "scaffold tests"
git config --global user.email "tests@scaffold.invalid"
# `scaffold new` commits, and a commit can hand the repository to a detached
# `git gc`. That process outlives the command and keeps writing into .git,
# which is the likeliest thing remove_workdir below is racing. A throwaway
# repository has nothing worth maintaining.
# A commit can hand the repo to a detached `git gc` that outlives the
# command and keeps writing into .git — the likely race remove_workdir waits out.
git config --global gc.auto 0
fi

# mise records every config it trusts under its state directory, so a suite
# that generates projects in throwaway tmpdirs writes an entry per run into
# the developer's real store and never removes it — it had grown past 7600
# stale `tmp.*-demo` entries. Owned by the test, like GIT_CONFIG_GLOBAL above,
# which also makes the trust behaviour itself observable: the real store is
# pre-trusted on a runner, so a test asserting an untrusted parent could only
# ever skip there.
if [ -z "${MISE_STATE_DIR:-}" ]; then
# mise records every config it trusts under its state directory; owned here
# like GIT_CONFIG_GLOBAL above, so a suite generating projects in tmpdirs
# doesn't write into the developer's real (and already pre-trusted) store.
if [[ -z "${MISE_STATE_DIR:-}" ]]; then
MISE_STATE_DIR="${BATS_TEST_TMPDIR:-${BATS_SUITE_TMPDIR:-/tmp}}/mise-state"
export MISE_STATE_DIR
fi
Expand All @@ -44,19 +33,16 @@ fi
# line that failed and nothing about why.
assert_ok() {
# shellcheck disable=SC2154 # status/output set by bats' run in the calling test
[ "$status" -eq 0 ] || {
echo "exit status ${status}; command output follows:"
echo "$output"
((status == 0)) || {
printf 'exit status %s; command output follows:\n' "$status"
printf '%s\n' "$output"
false
}
}

# copy_toolbox — a private copy of the toolbox for a test that has to modify
# it. Two tests need to see how the scripts behave against a broken adapter or
# a drifted file; editing the real tree made them race each other once the
# suites started running with --jobs, and one left ADAPTER_TIER="Z" behind in
# a tracked file. The scripts resolve their own root from their location, so
# running them out of the copy is enough.
# A private copy of the toolbox, for a test that modifies it and would
# otherwise race the real tree under --jobs. The scripts resolve their own
# root from their location, so running them out of the copy is enough.
copy_toolbox() {
local dest="${BATS_TEST_TMPDIR}/toolbox"
mkdir -p "$dest"
Expand All @@ -69,16 +55,9 @@ copy_toolbox() {
printf '%s' "$dest"
}

# remove_workdir — teardown's `rm -rf`, retried.
#
# `rm -rf` reports "Directory not empty" when an entry appears after it walked
# the directory, so a background process still writing into a generated project
# fails a teardown that has nothing to do with what the test asserted. Seen on
# CI against `tests/compose.bats`, which generates a project per test under
# --jobs, and on a different test each run; never reproduced locally, and the
# writer was never caught in the act, so this waits the race out rather than
# naming a cause. A tree that is genuinely stuck still fails, and says what is
# left in it.
# teardown's `rm -rf`, retried: it reports "Directory not empty" when a
# background writer still touches a generated project, which has nothing to
# do with what the test asserted.
remove_workdir() {
local -r dir="$1"

Expand All @@ -88,7 +67,7 @@ remove_workdir() {
done

rm -rf "$dir" && return 0
echo "could not remove ${dir}, left behind:" >&2
printf 'could not remove %s, left behind:\n' "$dir" >&2
find "$dir" >&2
return 1
}
13 changes: 7 additions & 6 deletions tests/install.bats
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ INNER_EOF
}

@test "fetch_release_asset uses the api asset endpoint when a token is set" {
# A Bearer token on the browser URL returns 404 for a private repository
# measured 2026-09-07 — so the endpoint has to change, not just the headers.
# A Bearer token on the browser URL returns 404 for a private repository,
# so the endpoint has to change, not just the headers.
mkdir -p stub2
cat >stub2/curl <<'INNER_EOF'
#!/usr/bin/env bash
Expand Down Expand Up @@ -112,12 +112,13 @@ INNER_EOF

@test "start_stack logs in to ghcr only when a token is set" {
# A package's ghcr visibility is separate from its repository's — a
# private package refuses an anonymous pull with `unauthorized`, measured
# 2026-09-07. A public client pulling a public image must never be asked
# to authenticate, so the no-token half here must see no login at all.
# private package refuses an anonymous pull with `unauthorized`. A public
# client pulling a public image must never be asked to authenticate, so
# the no-token half here must see no login at all.
mkdir -p stub3
cat >stub3/docker <<'INNER_EOF'
#!/usr/bin/env bash
[[ "$1" == login ]] && cat >/dev/null
printf '%s\n' "$*" >> "${DOCKER_LOG}"
INNER_EOF
chmod +x stub3/docker
Expand Down Expand Up @@ -152,7 +153,7 @@ INNER_EOF
@test "fetch_release_asset with no token names GITHUB_TOKEN on failure" {
# Without this the operator sees only curl's `(22) ... 404`, which reads as
# "no such release" rather than "you are not signed in" — see the comment
# on fetch_release_asset for the private-repository measurement behind it.
# on fetch_release_asset for why a private repository behaves that way.
mkdir -p stub4
cat >stub4/curl <<'INNER_EOF'
#!/usr/bin/env bash
Expand Down
10 changes: 4 additions & 6 deletions tests/service.bats
Original file line number Diff line number Diff line change
Expand Up @@ -434,10 +434,9 @@ EOF
@test "apply_service_drivers points a driver at the real project root, not the app's own ancestor" {
# scaffold add creates its app one directory below the project root
# ("worker", not "apps/worker"); a driver that assumed the fixed
# apps/<role> depth would land two directories above the app instead —
# reproduced directly against the pre-fix nest.sh, which mutated this
# decoy file with exit 0 and never noticed the real root had no
# pnpm-workspace.yaml of its own.
# apps/<role> depth would land two directories above the app instead,
# silently mutating a decoy file there rather than failing on the real
# root's missing pnpm-workspace.yaml.
local root="${BATS_TEST_TMPDIR}/rootcheck/project"
local decoy="${BATS_TEST_TMPDIR}/rootcheck"
local app="${root}/worker"
Expand Down Expand Up @@ -934,8 +933,7 @@ _password_literal_report() {
# A project generated with --db none keeps this file as shipped: nothing
# splices a probe in, so nothing in ready() awaits. Shipping it `async`
# made every --db none project fail @typescript-eslint/require-await on
# its own lint task — green-on-generation is the whole promise (ADR-0021),
# and two of the sixteen nightly service cells were red on it.
# its own lint task — green-on-generation is the whole promise (ADR-0021).
#
# Both halves, because either alone is satisfiable by a broken file: the
# shipped file must not say async, and the driver must be the thing that
Expand Down
14 changes: 7 additions & 7 deletions tests/wizard.bats
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,11 @@ strip_ansi() {

@test "Ctrl-D at the name prompt exits instead of spinning forever" {
# read returns 1 on EOF, leaving name empty; tui_name_is_usable "" fails,
# and pre-fix the loop just re-asked forever. The TTY guard in main only
# keeps a non-terminal stdin out — it does nothing about Ctrl-D on a real
# one, so this drives tui_prompt_name itself under a pty. Bounded by an
# outer timeout so a regression here fails this test instead of hanging it.
# and a loop with no EOF check just re-asks forever. The TTY guard in main
# only keeps a non-terminal stdin out — it does nothing about Ctrl-D on a
# real one, so this drives tui_prompt_name itself under a pty. Bounded by
# an outer timeout so a regression here fails this test instead of hanging
# it.
command -v script >/dev/null || skip "script(1) not available"

local driver="${BATS_TEST_TMPDIR}/drive-name-prompt.sh"
Expand Down Expand Up @@ -634,9 +635,8 @@ EOF
#
# Both run under a pty. tui_header measures with `tput cols`, which reads
# the terminal, not $COLUMNS — so without a pty `stty cols` fails silently,
# tput falls back to 80, and both cases draw the same 80-column box. That
# is how the first version of this test passed here and failed on CI: the
# width it claimed to set was never the width being measured.
# tput falls back to 80, and both cases draw the same 80-column box: a
# width this test would claim to set without ever actually measuring it.
command -v script >/dev/null || skip "script(1) not available"

local body="source '${SCAFFOLD_ROOT}/lib/tui.sh'; tui_header"
Expand Down
10 changes: 4 additions & 6 deletions tests/workflows.bats
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ setup() {
REAL_HOME="$HOME"
# Redirecting HOME to hide a git identity moves mise's data directory with
# it, and an empty one means every generated project re-downloads node,
# pnpm, lefthook and gitleaks. That is minutes per test and, worse, four
# network calls that can fail for reasons this suite is not testing —
# measured twice: `peer closed connection without sending TLS close_notify`
# while fetching pnpm, failing a test about git identity. The tests below
# point it back at the real store; HOME stays redirected, which is the
# thing they actually need.
# pnpm, lefthook and gitleaks — minutes per test, and network calls that
# can fail for reasons this suite is not testing. The tests below point it
# back at the real store; HOME stays redirected, which is the thing they
# actually need.
REAL_MISE_DATA_DIR="${MISE_DATA_DIR:-${HOME}/.local/share/mise}"

# a test that redirects HOME loses git's identity with it, and
Expand Down