SmartTest

Benchmarks for 1.0.0

The 1.0.0 benchmark story: at least five real repositories, LeadsIt included, replayed with smarttest benchmark at 0 missed failures, and the targets 0.5.0 (observed execution) and 0.8.0 (speed) missed on LeadsIt re-measured there. Earlier results: 0.6.0 external repositories, LeadsIt.

How it was measured #

Result: 0 missed failures on six repositories (seven with Galdia) #

RepositoryRunnerTest filesCommitsFull runsSmartTestSavedTest files runFull-suite fallbacksMissedAlready failing
tj/commander.jsnode --test1091212.6 s7.0 s45%644 / 1,312506
pillarjs/path-to-regexpVitest31232.2 s25.2 s22%30 / 36400
unjs/ufoVitest13124.8 s4.7 s4%99 / 156700
unjs/defuVitest2124.1 s3.4 s19%14 / 24600
LeadsItJest, Vitest, node --test1031247.5 s6.9 s85%5 / 1,225000
OpenPro front end (private)Vitest2,085611 min 19 s2 min 14 s80%2,121 / 12,49610100

"Full-suite fallbacks" are commits where a mandatory trigger (a lockfile, package.json, a runner config) ran everything. "Test files run" counts test-file runs summed over the replayed commits.

Project-gated baseline: LeadsIt's per-project suites took 12.8 s, so SmartTest was 46% faster than a per-project hook. The four public repositories are single projects, so their gated time equals the full run. OpenPro is a single project too, so its gated time equals the full run (11 min 19 s). Before the fix described below, the gated column read 22 min 37 s.

Per repository #

OpenPro front end #

About 9,600 TypeScript files and 2,164 test files (2,086 Vitest, 78 Playwright), installed with npm. This is the largest workload. Three things differ from the other repositories:

CommitChangeSelectedFullSmartTestFailing in the full runAlready failingMissed
c01bbca7missions: clip buffering11 / 2,073112.7 s6.4 s21200
d1ac6e05missions: rendition warm-up14 / 2,084112.7 s4.3 s21200
1c8c4043dependency patches (lockfile)2,084 / 2,084112.7 s114.3 s2100
4769749fmedia: sound-track polling4 / 2,085114.3 s3.8 s21200
aa5daf8dlinks: listener cleanup4 / 2,085113.6 s2.7 s21200
67d875cbstories: voice channels across refreshes4 / 2,085112.7 s2.8 s21200

0.5.0 target: observed execution reduces the selection by 20% #

Definition (0.5.0 gate): on the benchmark repositories, traces from observed execution reduce the selection by 20% or more, with 0 missed failures. Measured with smarttest benchmark --commits 12 --traces, which traces every test file at each parent commit (smarttest trace, V8 coverage) before selecting. That is the method LeadsIt used in 0.5.0. The comparison covers commits without a full-suite fallback, in the default BALANCED mode.

RepositoryStatic selectionWith tracesTests ruled out by tracesTests added by tracesMissed
LeadsIt5803 (c4ad92a4)0
commander.js97110013 (373f660f)0

Not met. Traces ruled out no test on either repository. They added tests instead: in BALANCED mode a test is also selected when its trace ran a changed unit that static analysis does not reach (observed-execution).

Why on LeadsIt: the three added tests import unchanged helpers (giftLeadIdsOf, giftOfJob) from the changed growth/gift/operations.ts. The change adds a new function, copyGift, and a type alias. Neither unit has a V8 function name at the parent commit, and for such units loading the file counts as running it. That over-selects, but safely: the full runs passed. Static selection was already method-level and narrow (5 test files over 12 commits), which leaves traces almost nothing to remove. This is what 0.5.0 found too (11 → 10).

On commander.js, 373f660f replaces a helper module with node:util. Static analysis selects 96 of 109 test files and the traces show the other 13 loading the changed file. Every commit that changes package.json or the lockfile runs everything either way.

0.8.0 target: 30% less test time than 0.7.0 #

Definition (0.8.0 gate): 30% less test time than 0.7.0 on the benchmark repositories, with 0 cache-integrity violations. Measured as SmartTest's total time (analysis plus execution) in benchmark --commits 12, with SmartTest 0.7.0 (tag v0.7.0, packed the same way) and the 1.0.0 candidate on the same clones, windows and machine, run one after the other.

Repository0.7.01.0.0 candidate, before merging the 1.0.0 performance work1.0.0 candidateChange
LeadsIt6.3 s6.6 s6.9 s+10%
commander.js7.7 s8.2 s7.0 s−9%
path-to-regexp23.8 s24.4 s25.2 s+6%

Not met. The difference is within run-to-run noise on all three. Cache-integrity violations: 0. The validated result cache never hits during a replay: each replayed commit is a state that has not been run before, and the benchmark runs SmartTest once per commit. What 0.8.0 added (the result cache, parallel runner groups, fail-fast ordering) helps a repeated run of the same state and multi-runner suites. A replay of fresh commits measures neither, and the remaining time is dominated by starting the runners and by analysis.

Analysis time on LeadsIt #

1,608 files, 8,403 symbols, 24,916 graph edges. The graph is now persisted in .smarttest/state/graph.json.

Operation0.6.01.0.0 candidate
Cold scan (no .smarttest/)3.6 s1.73 s (index 828 ms, all 1,608 files parsed)
Warm scan—0.28 s
Cold impact --base HEAD~1—1.81 s wall, 1,466 ms analysis
Warm impact --base HEAD~1—0.48 s wall, 471 ms analysis (graph loaded in 14 ms)

The persisted graph cut graph time on a warm run from 42 ms (rebuilt) to 14 ms, and the warm impact from 0.61 s to 0.48 s wall. On a warm run, change detection (Git, 316 ms) is now the largest step. A full run through SmartTest (run --all): 724 tests passed in 103 files in 4.3 s.

Problems found and fixed in SmartTest #

Found onProblemFix
path-to-regexp@borderless/ts-scripts points core.hooksPath into node_modules/…/husky/node. init --yes wrote SmartTest's block into that file, which belongs to the package and is replaced on the next installA hooks path inside node_modules is refused (ST_HOOK_PATH_IN_DEPENDENCY). init reports a hook it must not edit (also outside the repository, or not a shell script) as a warning and sets up everything else, where an outside hooks path used to make init fail
OpenPro front endvitest.config.ts next to vitest.stryker.config.ts makes two Vitest roots in one directory. benchmark keyed group times by runner and directory only, so the project-gated baseline counted the one Vitest run twice (226 s for a 113 s full run)Group times are keyed by runner, directory and config file. Execution was not affected: the standard config owns the files
OpenPro front endinit validation listed both roots as "vitest (.) discovers 2086 test file(s)", each without its configEach root is listed with its own config file

Tests: packages/smart-init/src/hooks.test.ts (hooks path in node_modules), integration-tests/adoption.test.ts (init with a refused hook), integration-tests/real-world.test.ts (two configs of one runner in the project-gated baseline; fails without the fix).

No selection bug was found: 0 missed failures everywhere.

Galdia, after the pre-publication audit #

Galdia, a pnpm workspace with 9 packages, 229 Vitest files in four projects and 31 Playwright specs, was set up with init --yes and replayed on its last 12 commits. Only Vitest was replayed: the Playwright specs stay in its pre-push gate (hook.runners: [vitest]).

BuildFull runsSmartTestSavedTest files runAnalysis completeMissedAlready failing
1.0.0 as first tagged4 min 32 s32.2 s88%197 / 2,6620 of 12 commits0132
1.0.0 with the audit fixes4 min 14 s9.9 s96%29 / 2,66212 of 12 commits0152

The first run found that import icon from "#web/assets/icons/galdia.svg?raw" did not resolve: a bundler query behind a path alias. Every test reaching Icon.tsx was therefore uncertain and selected. Resolving it made every commit's analysis complete and cut the selection from 197 to 29 files, still with 0 missed failures. The "already failing" tests are a starter-deck update in that history that broke tests before a later commit fixed them; they fail on the parent commit too.

The same audit, in fresh installs, found a missed failure that none of the replays had hit. A new export whose initializer throws at import was not seen as affecting the file's importers. It is fixed: code that runs at import now affects every importer. On the fixtures' 92 one-declaration edits this changed no selection.

Not covered #

Edit this page on GitHub