Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
852384f683 feat(quality): complete test:scoped — --full map rebuild, stdin selection, CI loader parity (#8084 D1)
- test:scoped:full (documented in the script header since #9143 but never wired) rebuilds
  config/quality/test-impact-map.json and then selects.
- select-impacted-tests.mjs gains --stdin so --staged selects from the index; the git-diff
  path only ever saw commits, so staged-only runs silently fell back to the heuristic.
- Loader parity with npm run test:unit / quality.yml TIA step (#6787): tests/unit/dashboard/**
  under --import tsx (CJS transform), tests/unit/serial/** at --test-concurrency=1, the rest
  under tsx/esm. The single tsx/esm invocation false-redded every dashboard test the map
  selected ("Unexpected token 'export'").
- CONTRIBUTING.md → Running Tests documents the three modes and the fail-safe exit 1.

Refs #8084
2026-09-01 16:55:07 -03:00
5 changed files with 138 additions and 78 deletions

View File

@@ -177,6 +177,13 @@ npm run test:all
# Single test file (Node.js native test runner — most tests use this)
node --import tsx/esm --test tests/unit/your-file.test.ts
# Only the unit tests impacted by your change (same TIA selector as the CI gate, #8084)
npm run test:scoped # changes in the last commit (or the working tree)
npm run test:scoped:staged # staged changes only — pairs well with a pre-commit run
npm run test:scoped:full # rebuild the import-graph map first (after adding/moving files)
# Exit 1 + "run the full suite" means a hub file (tsconfig, package.json, …) or an
# unmapped source changed — the selector fails safe, it never silently skips.
# Vitest (MCP server, autoCombo, cache)
npm run test:vitest

View File

@@ -126,6 +126,7 @@
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:scoped": "bash scripts/quality/test-scoped.sh",
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
"test:scoped:full": "bash scripts/quality/test-scoped.sh --full",
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",

View File

@@ -39,7 +39,18 @@ export function selectImpacted({ changed, map }) {
return [...out].sort();
}
// `--stdin`: read the changed-file list from stdin (one path per line) instead of
// diffing git. Used by scripts/quality/test-scoped.sh so `--staged` selects from the
// index — the git-diff path here only knows about commits, never the working tree.
export function changedFilesFromStdin(text) {
return String(text || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
function changedFiles() {
if (process.argv.includes("--stdin")) return changedFilesFromStdin(fs.readFileSync(0, "utf8"));
const baseRef = process.env.GITHUB_BASE_REF;
const baseTarget = process.env.GITHUB_BASE_SHA || (baseRef ? `origin/${baseRef}` : "HEAD~1");
const stdout = execFileSync(

View File

@@ -2,25 +2,46 @@
# test-scoped — run only unit tests impacted by your changes.
#
# Usage:
# npm run test:scoped # tests for changes vs HEAD~1
# npm run test:scoped -- --staged # tests for staged changes only
# npm run test:scoped # tests for changes vs HEAD~1 (working tree if no commit)
# npm run test:scoped:staged # tests for staged changes only
# npm run test:scoped:full # rebuild the import-graph impact map first, then select
#
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
# builds a full import-graph impact map; for local dev we use a fast heuristic:
# This is the local DX companion to the CI TIA gate (#8084 D1). It uses the SAME
# selector as CI (scripts/quality/select-impacted-tests.mjs) against the import-graph
# impact map (config/quality/test-impact-map.json, gitignored):
# - Changed test files → run those directly
# - Changed source files → run tests that share the file's directory/name prefix
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
# - Changed source files → run every unit test whose import graph reaches them
# - Hub files (tsconfig, package.json, …) or unmapped sources → full suite (fail-safe)
#
# For the full TIA (import-graph based), use: npm run test:scoped:full
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
# The map is a snapshot of the import graph: rebuild it (`--full`) after adding tests,
# moving files, or pulling a big base update — a stale map falls back to __RUN_ALL__
# for unknown sources, never to a silent skip.
#
# Loader parity with `npm run test:unit` / CI (#6787): tests/unit/dashboard/** runs
# under `--import tsx` (CJS transform — required for ESM-only deep imports such as
# @lobehub/icons/es/*), tests/unit/serial/** at --test-concurrency=1, everything else
# under `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
# test the map selects ("Unexpected token 'export'").
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
STAGED=false
FULL=false
for arg in "$@"; do
case "$arg" in
--staged) STAGED=true ;;
--full) FULL=true ;;
-h|--help) sed -n '2,25p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "[test:scoped] unknown argument: $arg (use --staged, --full)"; exit 2 ;;
esac
done
# ── 1. Determine changed files ───────────────────────────────────────────────
if [[ "${1:-}" == "--staged" ]]; then
if [ "$STAGED" = true ]; then
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
else
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
@@ -32,80 +53,51 @@ if [ -z "$CHANGED" ]; then
exit 0
fi
# ── 2. Classify changes ──────────────────────────────────────────────────────
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
TEST_FILES=()
SRC_FILES=()
HIT_HUB=false
# ── 2. Impact map (build on --full or when missing) ──────────────────────────
if [ "$FULL" = true ] || [ ! -f "$MAP_FILE" ]; then
echo "[test:scoped] Building the import-graph impact map (config/quality/test-impact-map.json)…"
(cd "$REPO_ROOT" && node scripts/quality/build-test-impact-map.mjs)
fi
while IFS= read -r f; do
[ -z "$f" ] && continue
if echo "$f" | grep -qE "$HUB_RE"; then
HIT_HUB=true
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
TEST_FILES+=("$f")
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
SRC_FILES+=("$f")
fi
done <<< "$CHANGED"
# ── 3. Select impacted tests (same selector as the CI TIA gate) ──────────────
SEL=$(printf '%s\n' "$CHANGED" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" --stdin)
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
if [ "$HIT_HUB" = true ]; then
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "[test:scoped] Hub file or unmapped source changed — run the full suite: npm run test:unit"
echo "[test:scoped] (if you just added a source file, rebuild the map: npm run test:scoped:full)"
exit 1
fi
# ── 4. Collect tests to run ──────────────────────────────────────────────────
RUN_TESTS=()
mapfile -t RUN_TESTS < <(printf '%s\n' "$SEL" | grep -v '^$' | sort -u)
# Direct test file changes always run
for tf in "${TEST_FILES[@]}"; do
RUN_TESTS+=("$tf")
done
# For source files, try the impact map first; fall back to heuristic
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
# Use the TIA selection with the impact map
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
exit 1
fi
while IFS= read -r t; do
[ -n "$t" ] && RUN_TESTS+=("$t")
done <<< "$SEL"
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
# No impact map — heuristic: suggest building it
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
echo "[test:scoped] Or run the full suite: npm run test:unit"
echo ""
echo "[test:scoped] Changed source files:"
printf ' %s\n' "${SRC_FILES[@]}"
if [ ${#TEST_FILES[@]} -gt 0 ]; then
echo "[test:scoped] Running changed test files only..."
else
exit 1
fi
fi
# Deduplicate
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
if [ ${#SORTED[@]} -eq 0 ]; then
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
if [ ${#RUN_TESTS[@]} -eq 0 ]; then
echo "[test:scoped] No impacted unit tests — the change does not reach any node:test file."
exit 0
fi
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
echo "[test:scoped] Running ${#RUN_TESTS[@]} impacted test(s)..."
# ── 4. Split by loader (mirror package.json test:unit / quality.yml TIA step) ──
DASH=(); SERIAL=(); REST=()
for f in "${RUN_TESTS[@]}"; do
case "$f" in
tests/unit/dashboard/*) DASH+=("$f") ;;
tests/unit/serial/*) SERIAL+=("$f") ;;
*) REST+=("$f") ;;
esac
done
# ── 5. Run selected tests ────────────────────────────────────────────────────
cd "$REPO_ROOT"
exec cross-env \
DISABLE_SQLITE_AUTO_BACKUP=true \
node --max-old-space-size=8192 \
--import tsx/esm \
--import ./open-sse/utils/setupPolyfill.ts \
--import ./tests/_setup/isolateDataDir.ts \
--test --test-force-exit --test-concurrency=4 \
"${SORTED[@]}"
NODE_COMMON=(--max-old-space-size=8192 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit)
export DISABLE_SQLITE_AUTO_BACKUP=true
RC=0
if [ ${#REST[@]} -gt 0 ]; then
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=4 "${REST[@]}" || RC=$?
fi
if [ ${#DASH[@]} -gt 0 ]; then
node --import tsx "${NODE_COMMON[@]}" --test-concurrency=4 "${DASH[@]}" || RC=$?
fi
if [ ${#SERIAL[@]} -gt 0 ]; then
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=1 "${SERIAL[@]}" || RC=$?
fi
exit $RC

View File

@@ -21,9 +21,7 @@ const MAP = {
"tests/unit/api/chat-route.test.ts",
"tests/unit/combo/combo-strategy.test.ts",
],
"src/shared/constants/routingStrategies.ts": [
"tests/unit/combo/combo-strategy.test.ts",
],
"src/shared/constants/routingStrategies.ts": ["tests/unit/combo/combo-strategy.test.ts"],
},
};
@@ -88,3 +86,54 @@ test("selectImpacted: non-source files are ignored (no __RUN_ALL__)", () => {
});
assert.deepEqual(sel, []);
});
// ── #8084 D1 completion: stdin mode + loader parity ─────────────────────────
import fs from "node:fs";
import path from "node:path";
import { changedFilesFromStdin } from "../../scripts/quality/select-impacted-tests.mjs";
test("changedFilesFromStdin: one path per line, trimmed, blanks dropped", () => {
assert.deepEqual(changedFilesFromStdin(" src/a.ts \n\nopen-sse/b.ts\r\n\n"), [
"src/a.ts",
"open-sse/b.ts",
]);
assert.deepEqual(changedFilesFromStdin(""), []);
assert.deepEqual(changedFilesFromStdin(undefined), []);
});
const SCRIPT = fs.readFileSync(
path.resolve(import.meta.dirname, "../../scripts/quality/test-scoped.sh"),
"utf8"
);
test("test-scoped.sh feeds the selector via --stdin (staged mode must not read git commits)", () => {
assert.match(SCRIPT, /select-impacted-tests\.mjs" --stdin/);
});
test("test-scoped.sh mirrors the CI loader split (#6787): dashboard→tsx, serial→concurrency=1, rest→tsx/esm", () => {
assert.match(SCRIPT, /tests\/unit\/dashboard\/\*\) DASH\+=/);
assert.match(SCRIPT, /tests\/unit\/serial\/\*\) SERIAL\+=/);
assert.match(
SCRIPT,
/node --import tsx "\$\{NODE_COMMON\[@\]\}" --test-concurrency=4 "\$\{DASH\[@\]\}"/
);
assert.match(
SCRIPT,
/node --import tsx\/esm "\$\{NODE_COMMON\[@\]\}" --test-concurrency=1 "\$\{SERIAL\[@\]\}"/
);
assert.match(
SCRIPT,
/node --import tsx\/esm "\$\{NODE_COMMON\[@\]\}" --test-concurrency=4 "\$\{REST\[@\]\}"/
);
});
test("package.json exposes every mode the script header documents", () => {
const pkg = JSON.parse(
fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8")
);
for (const name of ["test:scoped", "test:scoped:staged", "test:scoped:full"]) {
assert.ok(pkg.scripts[name], `missing script ${name}`);
assert.match(pkg.scripts[name], /scripts\/quality\/test-scoped\.sh/);
}
assert.match(pkg.scripts["test:scoped:full"], /--full/);
});