name: om-smart-test description: Run only the tests affected by changed code. Use when the user says "run affected tests", "run smart tests", "test only what changed", "run tests for this PR", "run tests for my changes", "selective tests", or asks to run tests without running the full suite.
Smart Test — Run Only Affected Tests
Runs the minimal set of tests that cover the code changes in the current branch or working tree.
Execution policy: Display the test plan (which tests will run and why), then immediately run them without asking for confirmation.
Cache: Analysis results are persisted to .test-cache.json (gitignored). On repeated invocations for the same commit with no uncommitted changes, the cached plan is reused — skip straight to running tests.
Two Test Types, Two Strategies
| Type | Files | Strategy |
|---|---|---|
| Jest (unit/component) | *.test.ts, *.test.tsx |
--findRelatedTests (Jest traverses import graph) |
| Playwright (integration) | *.spec.ts in __integration__/ |
Module-name matching via meta.ts dependency declarations |
Step 0 — Cache Lookup
Before doing any git analysis, check whether a valid cached plan already exists for the current state.
CURRENT_HASH=$(git rev-parse HEAD)
UNCOMMITTED=$(git diff --name-only HEAD; git ls-files --others --exclude-standard)
Read .test-cache.json (if it exists). The cache is valid when:
cache.commitHashequalsCURRENT_HASH, andUNCOMMITTEDis empty (no staged/unstaged/untracked changes), and- The commit is reachable from HEAD:
git merge-base --is-ancestor <cache.commitHash> HEAD 2>/dev/nullexits 0
git merge-base --is-ancestor "${cache.commitHash}" HEAD 2>/dev/null && echo "reachable" || echo "stale"
Condition 3 guards against stale cache entries after a rebase, amend, or force-push. The old hash may still exist as a dangling object in the git store (git cat-file -e would return true), but it is no longer part of the branch history — git merge-base --is-ancestor correctly rejects it.
If cache is valid: skip Steps 1–5, print [cache hit: <hash>], and run tests from the cached plan:
- Jest:
yarn jest --findRelatedTests <cache.jestSourceFiles> --passWithNoTests - Playwright: if
cache.integrationWideistrue→yarn test:integration; else ifcache.integrationSpecFilesis non-empty →yarn playwright test <cache.integrationSpecFiles> --config=.ai/qa/tests/playwright.config.ts; else skip.
If cache is invalid or missing: continue to Step 1. After completing Steps 1–2, write the cache (see "Save Cache" below) before running tests.
Cache file format (.test-cache.json)
{
"commitHash": "<git rev-parse HEAD>",
"savedAt": "<ISO timestamp>",
"scope": "module | wide | test-only | package",
"layer": "ui | ui-component | api-logic | data | mixed",
"affectedModules": ["auth", "sales"],
"jestSourceFiles": [
"packages/core/src/modules/auth/commands/users.ts"
],
"integrationSpecFiles": [
"packages/core/src/modules/auth/__integration__/TC-AUTH-001.spec.ts"
],
"integrationWide": false
}
integrationWide: true means the Python script returned --all; in that case integrationSpecFiles is empty and the full integration suite runs.
layer values: ui = skip Playwright; ui-component, api-logic, data, or mixed = run Playwright.
Save Cache
After completing the analysis (Steps 1–2), write the plan before running tests:
node -e "
const fs = require('fs');
const plan = {
commitHash: '$(git rev-parse HEAD)',
savedAt: new Date().toISOString(),
scope: '<scope>',
layer: '<ui|ui-component|api-logic|data|mixed>',
affectedModules: <json-array-of-modules>,
jestSourceFiles: <json-array>,
integrationSpecFiles: <json-array>,
integrationWide: <true|false>
};
fs.writeFileSync('.test-cache.json', JSON.stringify(plan, null, 2));
"
Step 1 — Determine Changed Files
Build one changed-file list and reuse it for cache invalidation, classification, Jest, and Playwright mapping. Include PR diff, local staged/unstaged changes, and untracked files:
First resolve the comparison base. Do not guess origin/main when the branch is based on
develop; comparing a develop-based branch to origin/main can pull in unrelated
packages/shared/ changes from the long-lived develop branch and incorrectly force the full
suite.
BASE_REF="${SMART_TEST_BASE_REF:-}"
if [ -z "$BASE_REF" ]; then
BASE_REF="$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream} 2>/dev/null || true)"
fi
if [ -z "$BASE_REF" ] && git rev-parse --verify --quiet origin/develop >/dev/null; then
if git merge-base --fork-point origin/develop HEAD >/dev/null 2>&1 || git merge-base --is-ancestor origin/develop HEAD; then
BASE_REF="origin/develop"
fi
fi
if [ -z "$BASE_REF" ] && git rev-parse --verify --quiet develop >/dev/null; then
if git merge-base --fork-point develop HEAD >/dev/null 2>&1 || git merge-base --is-ancestor develop HEAD; then
BASE_REF="develop"
fi
fi
CHANGED_FILES=$({
if [ -n "$BASE_REF" ]; then
git diff --name-only "$BASE_REF"...HEAD
fi
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | awk '!seen[$0]++')
If git diff --name-only origin/main...HEAD contains packages/shared/ but $BASE_REF is
origin/develop/develop and the $BASE_REF...HEAD diff does not contain
packages/shared/, do not classify the branch as wide-scope. Report it as a base-ref
mismatch and use the resolved develop base.
If there is no upstream PR context, use only local changes and untracked files:
CHANGED_FILES=$({
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | awk '!seen[$0]++')
Step 2 — Classify Scope and Layer
2a — Scope
Read the changed file list and classify scope:
- Wide scope (run everything): changes in
packages/shared/,packages/events/,packages/queue/,packages/cache/, rootjest.config.cjs,jest.setup.ts,tsconfig*.json - UI-wide (
packages/ui/src/backend/): shared React components rendered on every backend page — Jest:--findRelatedTests; Playwright: full suite (the Python script outputs--allfor these paths). Forpackages/ui/src/primitives/orpackages/ui/src/styles/only, classify asuilayer instead (no Playwright). - Module-scoped:
packages/*/src/modules/<module>/orapps/mercato/src/modules/<module>/→ extract<module> - Package-scoped (no module):
packages/<pkg>/src/lib/orpackages/<pkg>/src/root — treat as wide scope for that package - Jest-test-only: only
.test.ts/.test.tsxfiles changed → run those files directly via Jest; skip Playwright - Playwright-test-only: only
.spec.tsfiles inside__integration__/changed → run those files directly viayarn playwright test <files> --config=.ai/qa/tests/playwright.config.ts; skip Jest
See references/test-architecture.md for module extraction patterns and known cross-module integration dependencies.
2b — Layer (determines whether Playwright runs)
After determining scope, classify the layer of each changed source file. Integration (Playwright) tests only need to run when backend logic or data is touched — they are irrelevant for pure UI changes.
Classify each changed file:
| Layer | Path indicators | Playwright needed? |
|---|---|---|
ui |
**/*.css · packages/ui/src/primitives/ · packages/ui/src/styles/ |
No |
ui-component |
packages/ui/src/backend/**/*.tsx · /components/ · /widgets/ · /frontend/ · /backend/**/*.tsx (Next.js pages) |
Yes — Playwright renders full pages; a broken component can crash a page load or break a selector |
api-logic |
/api/ · /commands/ · /lib/ · /services/ · /subscribers/ · /workers/ · events.ts · notifications.ts · ai-tools.ts |
Yes |
data |
/data/entities · /data/migrations · /data/validators · /data/extensions · /data/enrichers |
Yes |
Layer decision rule — set $LAYER as a shell variable:
- All changed files →
uionly (CSS / design tokens / primitives) →LAYER=ui, skip Playwright - Any file →
datapatterns →LAYER=data, run Playwright - Any file →
api-logicpatterns (and none matchdata) →LAYER=api-logic, run Playwright - Any file →
ui-componentpatterns (and none matchapi-logicordata) →LAYER=ui-component, run Playwright - Files span multiple non-
uilayers →LAYER=mixed, run Playwright - Wide scope always → run everything
LAYER="<ui|ui-component|api-logic|data|mixed>" # required: used in Step 5 and cache
Why ui-component needs Playwright: integration tests render full pages. A React component that throws during render, a conditional that hides a button, or a changed DOM structure can all break Playwright selectors — even without touching any API.
Only skip Playwright when the change cannot affect DOM structure or interactivity: pure CSS, design tokens, Tailwind config, color/spacing primitives.
Special cases:
- Module
backend/page.tsx,backend/[id]/page.tsx— Next.js page files →ui-component(Playwright visits these pages) - Module
api/GET/route.ts,api/POST/route.ts→ API routes →api-logic
→ Save cache now (see Step 0 — Save Cache, include layer field) before proceeding to run tests.
Step 3 — Jest Unit Tests
Use Jest's built-in --findRelatedTests. It traverses the import graph from changed source files and discovers every test that (directly or transitively) imports them.
# Build the list of changed source files (exclude test files themselves)
CHANGED=$(printf '%s\n' "$CHANGED_FILES" \
| grep -E '\.(ts|tsx)$' \
| grep -v '\.test\.' \
| grep -v '\.spec\.' \
| grep -v '__tests__/' \
| grep -v '__integration__/' \
| tr '\n' ' ')
# Run related tests (passWithNoTests handles no-match gracefully)
yarn jest --findRelatedTests $CHANGED --passWithNoTests
Wide scope fallback: when CHANGED includes shared/events/queue/cache files, run the full Jest suite instead:
yarn test
Step 4 — Ensure Server Is Running (Integration Tests Only)
Before running any Playwright tests, verify the app is accessible on port 3000.
curl -sf http://localhost:3000 > /dev/null 2>&1
If the server is running (exit code 0): proceed directly to Step 5.
If the server is NOT running: build the project and start the production server:
# Build everything (packages + app)
yarn build
# Start production server in background
yarn start &
APP_PID=$!
# Wait up to 2 minutes for server to become ready
echo "Waiting for server on port 3000..."
SERVER_READY=0
for i in $(seq 1 60); do
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo "Server ready."
SERVER_READY=1
break
fi
sleep 2
done
if [ "$SERVER_READY" -eq 0 ]; then
echo "ERROR: Server did not become ready within 2 minutes. Aborting integration tests."
exit 1
fi
After tests finish, leave the server running — do not kill it.
Step 5 — Integration Tests (Playwright)
Layer gate: if layer = ui (all changed files are UI-only), skip this step entirely — Playwright tests are not affected by pure UI changes.
Otherwise, use the Python script to map changed modules → affected spec files.
Pass --layer so the script can apply the correct triggering rules:
SPEC_FILES=$(printf '%s\n' "$CHANGED_FILES" \
| python3 .ai/skills/om-smart-test/scripts/find_affected_integration_tests.py \
--project-root . \
--base auto \
--layer "$LAYER")
if [ "$SPEC_FILES" = "--all" ]; then
yarn test:integration
elif [ -n "$SPEC_FILES" ]; then
yarn playwright test $SPEC_FILES --config=.ai/qa/tests/playwright.config.ts
else
echo "No affected integration tests found."
fi
$LAYER is the value determined in Step 2b (ui-component, api-logic, data, or mixed).
Layer-aware dep filtering: when LAYER=ui-component, the script only runs tests whose
own module changed — it ignores cross-module dependsOnModules declarations. Rationale: a
changed page.tsx or React component cannot break another module's API calls; only tests
that actually visit those pages need to run.
Workspace scoping: the script compares module identity by both module name and runtime
root. For example, apps/mercato/src/modules/example and
packages/create-app/template/src/modules/example are separate example modules, so an
app-specific page change does not trigger template integration specs.
Wide scope: if the script outputs --all (triggered when shared deps changed), run the full integration suite.
Data layer: if layer = data (entities/migrations changed), integration tests are
particularly important. Run normally via the script — the mapping will include all tests for
the affected module including any that declare it as a dependency.
Step 6 — Report Results
After tests complete, summarize:
- Whether results came from cache (
[cache hit]) or fresh analysis - How many Jest tests ran vs full suite
- Which integration spec files ran and why (which changed module triggered each)
- Whether the server was already running or was built and started
- Any wide-scope fallback applied and why
Coverage percentages (always include at the end):
| Type | Ran | Total | % |
|---|---|---|---|
| Unit (Jest suites) | <ran> |
~485 | <ran/485 * 100>% |
| Integration (Playwright spec files) | <ran> |
~323 | <ran/323 * 100>% |
Totals come from references/test-architecture.md. Round to one decimal place.
Decision Tree
Step 0: .test-cache.json valid (hash + no uncommitted + reachable)?
└─ YES → run from cache; check integrationWide flag:
integrationWide=true → Jest: cached files + Playwright: yarn test:integration
integrationWide=false → Jest: cached files + Playwright: cached spec files (or skip)
└─ NO → analyze:
Step 2a — Scope:
└─ Only .test.ts/.test.tsx? → Jest: run those files directly; Playwright: skip
└─ Only .spec.ts (__integration__/)? → Jest: skip; Playwright: run those files directly
└─ shared/events/queue/cache/root config?
→ Full suite (yarn test + yarn test:integration)
└─ packages/ui/src/backend/? → Jest: --findRelatedTests; Playwright: full suite
└─ Module-scoped? → extract module name(s)
└─ Package lib (no module)? → --findRelatedTests for that package
Step 2b — Layer (for non-wide, non-test-only scopes):
└─ ALL files are pure CSS / design tokens / primitives?
→ LAYER=ui
→ Jest: --findRelatedTests <changed-src-files>
→ Integration: SKIP (no DOM structure change possible)
└─ ANY file is ui-component / api-logic / data?
→ LAYER=<ui-component|api-logic|data|mixed>
→ Jest: --findRelatedTests <changed-src-files>
→ Integration: check server → script maps modules → spec files
→ Set $LAYER → Save cache (with layer field) → run tests
Reference Files
references/test-architecture.md— full test structure, module path patterns, framework configs, known cross-module integration dependencies