Test & bench auto-detection
How lpm test and lpm bench pick the right runner — vitest, jest, or mocha.
lpm test and lpm bench both work the same way: scan your installed deps, pick a runner, exec it, forward arguments verbatim. No config, no per-runner subcommand, no lpm test --vitest flag.
This page documents the detection rules, what gets executed, and what's NOT in the auto-detection set today.
Detection order
For lpm test:
vitestindependenciesordevDependencies→ runsvitest runjestindependenciesordevDependencies→ runsjestmochaindependenciesordevDependencies→ runsmochascripts.testinpackage.json→ runs the user-defined script via the platform shell (sh -con Unix,cmd /Con Windows)- None of the above → error: "no test runner found. Install vitest/jest/mocha or add a 'test' script to package.json"
First match wins — vitest beats jest, jest beats mocha, and any installed runner takes precedence over scripts.test. The script fallback only fires for projects with no recognized runner installed.
For lpm bench:
vitestindependenciesordevDependencies→ runsvitest benchscripts.benchinpackage.json→ runs the user-defined script via the platform shell (sh -con Unix,cmd /Con Windows)- None of the above → error: "no benchmark runner found. Install vitest or add a 'bench' script to package.json"
Vitest is the only auto-detected bench runner today. For other frameworks (mitata, tinybench standalone, hyperfine), define a bench script in package.json — lpm bench runs it directly via the script fallback above.
Argument forwarding
Anything after the command (or after --) is forwarded verbatim to the runner:
lpm test # → vitest run (or jest, or mocha)
lpm test src/utils.test.ts # → vitest run src/utils.test.ts
lpm test -- --reporter=verbose # → vitest run --reporter=verbose
lpm test -- --coverage --watch # → vitest --coverage --watch (see Watch mode)
lpm bench
lpm bench src/parser.bench.ts # → vitest bench src/parser.bench.ts
lpm bench -- --reporter=json-- is optional but useful for arguments that look like LPM flags (otherwise clap might try to interpret them).
Watch mode
Vitest's run subcommand forces single-pass execution — passing --watch after run is silently dropped. To make lpm test --watch actually enter watch mode, LPM rewrites the base command from vitest run to vitest whenever the forwarded args contain --watch (or the -w short form).
lpm test --watch # → vitest --watch
lpm test -- -w src/utils.test.ts # → vitest -w src/utils.test.tsOther runners are unaffected:
jestandmochaaccept--watchnatively against their bare command, so the rewrite is vitest-specific.lpm bench --watchworks as-is — vitest'sbenchsubcommand respects--watchdirectly.
If your project's package.json has a custom "test" script (the scripts.test fallback), LPM forwards --watch to that script verbatim without rewriting; the watch contract is whatever your script defines.
Why no per-runner subcommand
Other PMs ship pnpm test / npm test shortcuts that effectively npm run test. LPM's lpm test is not a script alias — it prefers the runner over the script:
| Command | Behavior |
|---|---|
lpm run test | Runs the test script from package.json (whatever you wrote there) |
lpm test | Auto-detects vitest / jest / mocha and runs it directly. Falls back to scripts.test only when no runner is installed. |
Why this shape: lpm test works in projects that don't have a test script — newly-bootstrapped repos, monorepo members where the test config is implicit, etc. If your test script does setup or env-injection beyond what the runner does on its own, reach for lpm run test explicitly so the script always wins; with lpm test, the installed runner wins over your script.
Subprocess passthrough
Both commands forward the runner's exit code:
lpm test
echo "exit: $?" # whatever vitest / jest / mocha exited withVitest exits 1 on failed tests; LPM exits 1. Vitest exits 0; LPM exits 0. No mangling. CI gates work the way you expect.
--json mode
Neither command emits a wrapping JSON envelope. The runner's own stdout (whatever shape it has) IS the result. This preserves the "single JSON result" contract — if you piped lpm test --json into a parser expecting LPM's JSON shape, you'd get the runner's output instead, which is what you actually want for test results.
Runtime
Tests run with whatever managed runtimes the runtime detection picks. So:
{ "runtime": { "node": ">=22.0.0" } }Then lpm test runs vitest on Node 22, regardless of your system Node. Auto-installs the version if missing (unless LPM_NO_AUTO_INSTALL=true).
Workspaces
lpm test and lpm bench accept --all, --filter, --filter-prod, --affected, --base, --changed-files-ignore-pattern, --test-pattern, --fail-if-no-match, and --workspace-concurrency — same grammar as lpm run and the lint/fmt/check tools.
lpm test --all
lpm test --filter web --filter api
lpm test --filter-prod ...shared
lpm test --filter './apps/*' --fail-if-no-match
lpm bench --affected --base develop --test-pattern '**/*.test.js' --workspace-concurrency 2Detection runs per member, so a workspace can mix runners (vitest in one member, jest in another) and each gets the right command. A member with no installed runner becomes a per-member detection failure in the JSON envelope rather than aborting the run.
--all is mutually exclusive with filters and --affected. Filters compose with --affected.
--workspace-concurrency <N> caps how many selected workspace members run at once within each topological level.
Forwarding the same flag names to the runner
Bun's bun test --filter and a few other runner flags share names with the LPM workspace flags. To forward them to the runner instead of having LPM claim them, put them after --:
lpm test -- --filter pattern # → bun test --filter pattern (or vitest, jest)
lpm test -- --all # → runner --all (e.g. jest's git-mode flag)
lpm test --filter web -- --filter pattern # workspace + runner filter composeAnything after -- is forwarded verbatim regardless of name.
Watch mode in workspaces
--watch interaction with workspace selectors is gated by selection size, not by whether a workspace flag was used at all:
| Selection resolves to | --watch behavior |
|---|---|
| Exactly one member | Hands off to single-package mode against that member's directory. The vitest run → bare vitest rewrite documented above kicks in. |
| Two or more members | Rejected with the actual count, to prevent N parallel watchers. |
| Zero members | Rejected with "nothing to watch." |
lpm test --filter web --watch # ✓ exactly one member matches
lpm test --all --watch # ✗ rejected: N watchers
lpm bench --filter '@scope/*' --watch # ✓ if exactly one member matches; ✗ otherwiseIf you want to watch a member from outside the workspace selection grammar, cd <member> && lpm test --watch works the same as the one-member-filter handoff.
See also
lpm test— CLI command referencelpm bench— bench equivalentlpm run— for workspace--filter/--affected- Managed runtimes — which Node and Bun binaries tests see on
PATH