From 17a53d2eb92eebbfdbe79051b8004039af082495 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 1 Sep 2026 23:01:40 -0300 Subject: [PATCH] =?UTF-8?q?feat(quality):=20complete=20test:scoped=20?= =?UTF-8?q?=E2=80=94=20--full=20map=20rebuild,=20stdin=20selection,=20CI?= =?UTF-8?q?=20loader=20parity=20(#8084=20D1)=20(#12353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CONTRIBUTING.md | 7 ++ package.json | 1 + scripts/quality/select-impacted-tests.mjs | 11 ++ scripts/quality/test-scoped.sh | 142 ++++++++++------------ tests/unit/test-scoped-selection.test.ts | 55 ++++++++- 5 files changed, 138 insertions(+), 78 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9a2f15606..effa3a7ddf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/package.json b/package.json index e881569761..8f12f4272e 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/scripts/quality/select-impacted-tests.mjs b/scripts/quality/select-impacted-tests.mjs index 8cf65100d8..ca50bca895 100644 --- a/scripts/quality/select-impacted-tests.mjs +++ b/scripts/quality/select-impacted-tests.mjs @@ -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( diff --git a/scripts/quality/test-scoped.sh b/scripts/quality/test-scoped.sh index f1c48c0c3b..f57c5140e7 100755 --- a/scripts/quality/test-scoped.sh +++ b/scripts/quality/test-scoped.sh @@ -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 diff --git a/tests/unit/test-scoped-selection.test.ts b/tests/unit/test-scoped-selection.test.ts index 9a626731f5..eb9d571832 100644 --- a/tests/unit/test-scoped-selection.test.ts +++ b/tests/unit/test-scoped-selection.test.ts @@ -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/); +});