#!/usr/bin/env bash # Partition unit test files into N shards by stable hash and run one shard. # # Usage: scripts/test-shard.sh # shard-index: 1-based (1..N) # total-shards: positive integer # # Excluded from sharding: # - test/e2e/* — need DATABASE_URL; run via bun run test:e2e # - *.serial.test.ts — concurrency-unsafe (file-wide mock.module / env # leaks); run via scripts/run-serial-tests.sh on # shard 1 only. Including these here lets their # mock.module() calls leak into the rest of the # shard's bun process and silently break unrelated # tests. See test/eval-takes-quality-runner.serial.test.ts # mocking gateway.ts → voyage-multimodal failures. # # Stable partitioning: a file's shard is `(hash(path) % N) + 1`. Same file # lands in the same shard on every run, regardless of how many other files # exist, so retries are reproducible. Hash is FNV-1a — pure shell, no jq. set -euo pipefail DRY_RUN_LIST=0 if [ "${1:-}" = "--dry-run-list" ]; then DRY_RUN_LIST=1 shift fi if [ "$#" -ne 2 ]; then echo "usage: scripts/test-shard.sh [--dry-run-list] " >&2 exit 1 fi SHARD_INDEX="$1" TOTAL_SHARDS="$2" if ! [[ "$SHARD_INDEX" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL_SHARDS" =~ ^[0-9]+$ ]]; then echo "error: shard index and total must be positive integers" >&2 exit 1 fi if [ "$SHARD_INDEX" -lt 1 ] || [ "$SHARD_INDEX" -gt "$TOTAL_SHARDS" ]; then echo "error: shard index $SHARD_INDEX out of range 1..$TOTAL_SHARDS" >&2 exit 1 fi cd "$(dirname "$0")/.." # Find all unit test files, deterministic order. Excludes test/e2e/ and # *.serial.test.ts. Serial files share file-wide state (top-level # mock.module, module singletons) that leaks across files in the same # `bun test` shard process — see scripts/check-test-isolation.sh R2. # CI runs them via `bun run test:serial` (scripts/run-serial-tests.sh) at # --max-concurrency=1 in a separate step on shard 1. Local `bun run test` # already excludes them from the parallel pass and runs them after the # same way. Portable: avoid `mapfile` (bash 4+) so this runs on macOS # bash 3.2 too. FILES=() while IFS= read -r line; do FILES+=("$line") done < <(find test -name '*.test.ts' -not -name '*.serial.test.ts' -not -path 'test/e2e/*' | sort) if [ "${#FILES[@]}" -eq 0 ]; then echo "no test files found under test/" >&2 exit 1 fi # FNV-1a 32-bit hash of a string — implemented in pure bash so we don't depend # on python/openssl/etc on the runner. Output is decimal. fnv1a() { local str="$1" local h=2166136261 # FNV offset basis local i ord for (( i=0; i<${#str}; i++ )); do ord=$(printf '%d' "'${str:$i:1}") h=$(( (h ^ ord) & 0xFFFFFFFF )) h=$(( (h * 16777619) & 0xFFFFFFFF )) done echo "$h" } SHARD_FILES=() for f in "${FILES[@]}"; do hash=$(fnv1a "$f") bucket=$(( hash % TOTAL_SHARDS + 1 )) if [ "$bucket" -eq "$SHARD_INDEX" ]; then SHARD_FILES+=("$f") fi done if [ "$DRY_RUN_LIST" = "1" ]; then if [ "${#SHARD_FILES[@]}" -eq 0 ]; then exit 0 fi printf '%s\n' "${SHARD_FILES[@]}" exit 0 fi echo "shard $SHARD_INDEX/$TOTAL_SHARDS: ${#SHARD_FILES[@]}/${#FILES[@]} files" if [ "${#SHARD_FILES[@]}" -eq 0 ]; then echo "warning: shard $SHARD_INDEX has no files (rehash or reduce shard count)" >&2 exit 0 fi exec bun test --timeout=60000 "${SHARD_FILES[@]}"