Skip to content

Draft: agent-oriented test selection and automatic parallel coordination - #77

Draft
simulieren wants to merge 32 commits into
japa:5.xfrom
simulieren:feat/agent-loop-upstream
Draft

simulieren wants to merge 32 commits into
japa:5.xfrom
simulieren:feat/agent-loop-upstream

Conversation

@simulieren

@simulieren simulieren commented Oct 23, 2026 •

Copy link
Copy Markdown

Summary

Experimental branch against 5.x. Draft, not proposed for merge as-is. No package has been published.

This PR contains only @japa/runner code and tests. It also carries the automatic parallel coordinator and timing history those features build on, because upstream 5.x does not have them. @japa/core is the published 10.4.0; no core fork is included. It adds an opt-in path for running only the tests a change can affect, with machine-readable output for agents and CI. Default behaviour is unchanged.

Area Change
Selection --prepare-caches records file contents; --tests, --groups, --tags prune files before import
Changes --changed / --changed=auto runs only affected test files: imports, a resolver hook, an opt-in labelled heuristic, a full-suite fallback
Caches --cache-dir keeps both caches in a tracked directory
Baselines --allow-known-failures exits 0 for known-only failures; --update-baseline records them
Reporting --agent / --agent-file emit bounded JSON lines with locations and timings
Wrapper japa-agent prepares caches, runs changes, logs the command's output and prints a short digest of what to fix
Scheduling --workers=auto and japa-agent size the pool from recorded durations, capped by JAPA_MAX_WORKERS; workers run at lower priority; concurrent pools queue on a run lock
Timings Estimates learn from failing runs and are blended instead of overwritten
Concurrency Cache read-modify-write sequences take a lock

How it works

flowchart TD
    A["Agent edits code"] --> B["japa-agent"]
    B --> C{"Caches present?"}
    C -- "no" --> D["--prepare-caches<br/>import files, write caches"]
    C -- "yes" --> E["--changed=auto"]
    D --> E
    E --> F{"Changed paths known?"}
    F -- "no" --> G["Full suite"]
    F -- "yes" --> H["Select dependent test files"]
    H --> I["Planner prunes files that cannot match"]
    I --> J["Import selected files"]
    G --> J
    J --> K["Run tests"]
    K --> L["Agent JSON + delta + metrics"]
    L --> M{"Only known failures?"}
    M -- "yes" --> N["exit 0"]
    M -- "no" --> O["exit 1"]
    K -. "setup or planning error" .-> P["exit 2"]
Loading

The two caches answer different questions: the selection index knows what a file
contains, and the dependency map knows what a file imports.

flowchart LR
    subgraph prepare["--prepare-caches"]
        A["Import every test file"] --> B["Selection index<br/>file → titles, groups, tags"]
        A --> C["Dependency map<br/>test file → transitive imports"]
    end

    subgraph filter["Filtered or changed run"]
        D["--tests / --groups / --tags"] --> E{"Index fresh?"}
        E -- "yes" --> F["Skip files that cannot match"]
        E -- "no" --> G["Always import the file"]
        H["--changed"] --> I{"Path in dependency map?"}
        I -- "yes" --> J["Select dependent files"]
        I -- "no" --> K["Run the full suite"]
    end

    B --> E
    C --> I
Loading

Usage

# once per checkout (use your test entrypoint)
node bin/test.js --prepare-caches --cache-dir=.japa

# after an edit
node bin/test.js --changed=auto --agent --agent-file=report.jsonl

# or through the wrapper
npx japa-agent -- node --import=@poppinss/ts-exec bin/test.ts

Usage with AdonisJS

AdonisJS runs Japa through Ignitor.testRunner() in bin/test.ts. One line is required:
Adonis loads providers, preloads and config before configure(), so without it the
dependency map never completes and --changed always runs the full suite (Japa now warns
about this, see Warnings).

// bin/test.ts
import { configure, processCLIArgs, recordImportsFromStart, run } from '@japa/runner'

process.env.NODE_ENV = 'test'
recordImportsFromStart() // before new Ignitor(...)

Ace forwards the flags as they are, no -- needed:

# once per checkout, and after adding spec files
node ace test unit functional --prepare-caches

# after an edit: only the specs the change can reach
node ace test unit --changed=auto --agent-file=tests/results/agent.jsonl

# a targeted run with machine-readable results next to the usual reporters
node ace test unit --tests="returns 404" --agent-file=tests/results/agent.jsonl

# the wrapper: prepares caches if missing, runs --changed=auto, writes a delta
npx japa-agent --out=tests/results/agent.jsonl -- node --import=@poppinss/ts-exec bin/test.ts unit

# whole suite across workers
JAPA_WORKERS=6 npx japa-parallel -- node --import=@poppinss/ts-exec bin/test.ts unit

# an automatic pool, never more than 4 workers
JAPA_MAX_WORKERS=4 npx japa-parallel --workers=auto -- node --import=@poppinss/ts-exec bin/test.ts unit

An automatic pool takes one worker per ~10 s of recorded test time, up to 8 (or
JAPA_MAX_WORKERS). The ceiling keeps the small-selection behaviour: a fixed
JAPA_WORKERS starts a pool even for one file, JAPA_MAX_WORKERS still runs it in one
process. Workers start at nice 10, so a pool yields the CPU to an editor or a dev server.

Prefer --agent-file to --agent in an app with its own reporters (result files, guards):
--agent replaces every reporter so stdout is pure JSON, --agent-file adds the agent
reporter beside them.

Parallel isolation

Japa supplies JAPA_WORKER_ID and JAPA_WORKER_COUNT. The app owns database, Redis,
search and port isolation. Set them in bin/test.ts before the Ignitor boots: Adonis never
overwrites a variable already in process.env, and by the time a bootstrap file runs the
env service has already read .env.

const worker = Number(process.env.JAPA_WORKER_ID)
if (Number.isInteger(worker) && worker > 0) {
  process.env.DB_DATABASE = `myapp_test_w${worker}` // create it here: providers query it during boot
  process.env.REDIS_DB = String(worker) // and read REDIS_DB in config/redis.ts
  process.env.PORT = String(24_330 + worker)
}

What broke a real app under japa-parallel, and how it showed up:

Cause Symptom Fix
A test that times out while its work keeps running (here a backfill run over the whole registry) app.terminate() waits in the database provider for the open transaction; the worker exits without a report (observed, traced to the query) Scope the work so it finishes inside the test
A teardown hook arming setTimeout(process.exit, 1000) Can cut the end phase off the same way, since the report is written after teardown (possible, not observed) Exit only after run() resolves
A spec that relies on another file's cleanup, or a hand-written unique value, in a test database that is never truncated Fails only when sharding puts the two files in different workers Clean up in the spec that needs it; generate unique values
Vite's WebSocket server One port per worker, and an open server at shutdown server.ws: false under test
mail.fake(), sinon stubs, fake clocks left behind A test fails only when sharding changes which files share a process Restore in teardown
Real sleeps (setTimeout for 6 s in a lease test) Slow shards Fake the clock, or stub the wait

--prepare-caches imports files without running tests and skips global setup hooks, so it
is safe to run before a database reset.

Warnings

Wrong or missing setup no longer fails silently. Japa writes these to stderr, so agent
JSON on stdout stays parseable, and the agent summary carries the same text as
selection.fallbackReason.

Warning Fix
--changed is running the full suite: no dependency map yet Run once with --prepare-caches
... the dependency map is incomplete recordImportsFromStart() at the top of the entrypoint, then --prepare-caches
... N changed path(s) no test file imports (a, b, c) Add a resolver for lazily loaded files (controllers, jobs) or turn on selection.heuristic. A new spec selects itself and does not cause this
... this Node has no module.registerHooks Node 22.15+ or 23.5+
The dependency map is incomplete: N file(s) imported other files before Japa could record them Printed by --prepare-caches; same fix
[japa-parallel] N workers on M CPUs Fewer workers, or --workers=auto
worker N: exited with code C before writing its report See the table above

Trying it in another repository

The packages are not published, so install both tarballs into a disposable
checkout. The runner depends on the unpublished core prerelease; installing the
runner alone will not resolve it.

# 1. build the runner tarball from this branch
git clone https://github.com/simulieren/runner.git
cd runner
git checkout feat/agent-loop-upstream
npm install --ignore-scripts
npm run build

mkdir -p /tmp/japa-tarballs
npm pack --ignore-scripts --pack-destination /tmp/japa-tarballs

# 2. install into the target app without touching its manifests
cd /path/to/app
npm install --no-save /tmp/japa-tarballs/japa-runner-*.tgz

The runner resolves the published @japa/core@10.4.0; no core workspace tarball is
needed for this branch.

Then prime the caches once:

# AdonisJS
node ace test -- --prepare-caches --cache-dir=.japa

# other Japa entries
node --import=@poppinss/ts-exec bin/test.ts --prepare-caches --cache-dir=.japa

Try the loop:

# one test, machine-readable output
node ace test -- --tests="returns 404" --agent --agent-file=.japa/report.jsonl

# only files affected by git changes
node ace test -- --changed=auto --agent --agent-file=.japa/report.jsonl

# same, through the wrapper
npx japa-agent -- node --import=@poppinss/ts-exec bin/test.ts

Verify that selection is honest before trusting it: run the same filter once with
the index and once with JAPA_NO_SELECTION_INDEX=1. The test identities and exit
codes must match. The agent summary reports selection.fallback: true when a
filter could not narrow the import list.

Restore the app afterwards with npm ci (or whatever restores its lockfile), which
removes the --no-save packages.

Troubleshooting

  • --changed needs module.registerHooks; on older Node it safely falls back to
    the full suite. --tests and --prepare-caches still work.
  • Caches live in --cache-dir (or node_modules/.cache/japa) and are safe to
    delete.
  • Parallel runs still need per-worker database, Redis, search and port isolation
    from the app; Japa only supplies JAPA_WORKER_ID and JAPA_WORKER_COUNT.
  • --update-baseline only writes from a full run. Filtered runs warn and leave
    the file unchanged.

Safety rules

  • A changed test file is always imported; changed source files select their dependants.
  • A file the index or the dependency map has never seen is always imported. A changed
    spec the map has not recorded selects itself instead of widening the run.
  • A file with runtime-built titles (datasets) is never skipped by a title filter.
  • --changed falls back to the full suite for any unknown path.
  • A partial selection never rewrites a baseline.
  • Cache failures never fail a test run.

Benchmarks

Correctness was checked before timing.

Environment

Apple M2 Max, 12 logical CPUs, 96 GiB RAM, Node 24.19.0. Docker VM limited to 12
CPUs and 7.65 GiB. Application boot runs through @poppinss/ts-exec; the app is
a large production-style AdonisJS application with a known-failure baseline.

Runs are sequential. Where a range is shown it is separate runs, not a median
unless stated. The machine was shared, so absolute wall times moved by several
seconds between sessions.

Full suite

Configuration Wall time Result
Serial, reference 247.094 s all tests, known failures
Serial, fork 242.671 s same identities
4 workers, initial 86.453 s same identities
4 workers + lease clock + fixture hash 46.174 / 48.338 s same identities
4 workers, all changes 33.766 s same identities
6 workers, all changes 26.809 / 27.768 / 28.056 s all identities, no added failures
8 workers, retest 47.569 s all identities, no added failures

The six-worker median is 27.768 s, about 8.9× faster than the serial
reference and about 1.7× faster than the previous retained four-worker run.
The three six-worker runs were consecutive and complete. The 8-worker run was
rejected as an improvement: wall time tracked host load, not worker count.

A later session measured ~48–49 s for both this branch and unmodified code, with
load averages of 4–10. That session is labelled load-affected, not a regression.

Worker ceiling

Every worker boots the app and opens its own database connection pool, so past a
point more workers add CPU, not speed. One sample each, unit suite only, 8,595 tests,
same failures:

Workers Wall time CPU (user + sys)
8 (automatic, before the ceiling) 48.75 s 212 s
4 (JAPA_MAX_WORKERS=4) 44.47 s 122 s

The app now sets the ceiling to 4 in its scripts. The 8-worker run also kept the
Docker VM serving Postgres at about 3 cores.

Preparing caches

Variant Time Output
--prepare-caches, before skipping setup 9.04–9.38 s 648 files
--prepare-caches, skipping setup 9.68 s 648 files, 8,296 titles, 3 dataset files
Dependency map — 1,027 modules, 23,563 test links

Preparation executes no tests. The cost is roughly 4.8 s of boot plus a 3.3 s
import pass; application setup was only ~0.6 s with a prepared database.

Selecting one test

Metric Index disabled Index enabled Change
Files planned 648 4 −99.4%
Import phase 3,886 ms 748 ms 5.2× faster
Wall time 8.432 s 5.645 s −33%
Test identities 1 1 identical

A second pair measured 3,342 ms vs 860 ms of imports and 8.08 s vs 5.83 s of wall
time. The four planned files are the matching file plus three files that build
titles at run time and therefore cannot be pruned by a title filter.

Wall time still includes roughly 4.8–4.9 s of application boot. The import phase
is the stable, load-independent comparison.

Selecting by change

Metric Result
Edited source file 1 dependent test file planned, 5 tests
Files skipped 647
Identities vs --files identical
Unknown path falls back to the full suite

Failure baseline

Scenario Result
--update-baseline, serial 2 known failures written
--allow-known-failures exit 0
Same selection without the flag exit 1
Partial selection with update baseline unchanged
6-worker update 23 entries merged once by the coordinator
6-worker known-only run exit 0

The 6-worker update included four load-sensitive failures in addition to the
stable baseline. Each passed in isolation; they are treated as flakes, not
regressions.

Boot attribution

Measurement Result
bootstrap-loaded, default loader 4.4–4.7 s
bootstrap-loaded, ts-exec 4.98–5.08 s
bootstrap-loaded, tsx 3.49–3.51 s
NODE_COMPILE_CACHE warm 4.14–4.37 s
Synchronous ESM hook path (makeSyncRequest) 4.4 s CPU
Module compilation (compileSourceTextModule) 1.2 s CPU
All Adonis provider files tens of ms
Summed module load() time 149 ms across 4,397 modules

The loader path, not provider boot or TypeScript transformation, dominates. The
tsx result is a lead only: direct-loader samples were noisy (4.5–6.0 s) and the
full identity suite was not run under it, so it is not adopted here.

Correctness and regression checks

Check Result
Filtered run vs index disabled identical identities
Changed selection vs explicit file filter identical identities
Full parallel run all identities, no missing/extra/duplicate
Full run after the new features same known failures plus load-sensitive flakes
Dataset files never pruned by a title filter
Shared-module links 197 first-importer links → 23,563 closure links

Limitations

  • Six-worker results are separate runs, not a repeated-median study.
  • Wall times depend on host load; import-phase numbers do not.
  • The tsx loader has not passed the full identity suite.
  • Parallel worker counts above six were not retained.

Cross-app validation

Tested against three open-source AdonisJS apps. Each needed one migration change
because Japa 5 uses Node's fs.glob:

// before: tests/**/*.spec(.ts|.js)
// after:  tests/**/*.spec.ts
App Stack Original suite Fork
batosai/adonis-starter-kit Adonis 6.21, Japa 4.5 8/8 unit 8/8
oguzhankrcb/OTI Adonis 7.4, Japa 4.2 2/2 unit 2/2
adocasts/adocasts Adonis 6.20, Japa 4.4 functional 6 run, 4 pass (2 env/mail failures)

Feature checks on those apps:

Feature Verified
--prepare-caches all three, with --force-exit
Selection index + exact --tests starter and OTI
--changed=<path> starter: 1 planned, 20 skipped
--changed=auto starter: 1 planned, 20 skipped
--agent-file summary all three
japa-agent wrapper + delta starter
--update-baseline / --allow-known-failures starter: exit 1 then exit 0
--failed --failed-new starter: ran only the new failure
Parallel coordinator repo suite; external starter blocked by shared SQLite between workers

The Adocasts failures were placeholder SMTP/service settings, not selection or
reporting. Browser suites were not run because Playwright browsers were not
installed.

Full functional suite

The complete functional suite: 431 files, 5,038 tests. Every run collected
all 5,038 identities with no missing or duplicate tests.

Configuration Runs Wall time Failed
Serial 1 447.4 s 125
6 workers 1 184.9 s 123
8 workers 3 170.8 / 144.8 / 168.3 s 108 / 106 / 107

Eight workers was the fastest setting, roughly 2.6–3.1× faster than serial,
with noticeable run-to-run variance.

Failure comparison is noisy because this suite is not green in the lab even
serially (125 failures). Across the three 8-worker runs:

  • 105 failures were stable across all parallel runs;
  • 19 serial failures passed under parallelism;
  • 4 failures appeared in at least one parallel run but were not stable across all
    three.

The failure differences are consistent with load/order-sensitive tests, not a
selection or reporting mismatch. Identities were identical in every run.

Node versions

Node Result
24.19 103/103
22.23.3 103/103
22.14 suite cannot start: @poppinss/ts-exec needs registerHooks (22.15+)

The runner's own fallback was checked on 22.14 with the compiled build:
--changed=auto runs without crashing when registerHooks is absent and falls
back to the full suite. Practical floor for TypeScript/Adonis apps is Node
22.15
; Node 24 is recommended.

Precise --changed selection (map version 2)

Measured in the AdonisJS app below, the version 1 map made --changed useless: every
reachable source file selected all 1,079 test files. The dependency walk followed edges
into framework packages, and the framework loads most of the app at boot (routes,
providers, preloads, every module). Every test that imports @adonisjs/core therefore
"depended" on everything.

Change What it does
The walk stops at packages A test depends on the project files its own imports reach.
Boot set Project files a package loaded before the first test file. A change to one that no test reaches runs everything.
selection.resolvers Config hook: files a test reaches without importing them, such as the controller behind a route it requests. Resolver files are imported while the map is written, so their own imports are recorded.
selection.heuristic Off by default. For a path nothing reaches, guess tests by file name (lease.ts -> lease.spec.ts) and by prefix rules. The summary says "heuristic": true.
Map version 2 Version 1 maps are discarded, not trusted.
// tests/bootstrap.ts in the app
export const selection: Config['selection'] = {
  resolvers: [controllersRequestedBy], // route table + urlFor()/literal paths in the spec
  heuristic: [{ from: 'app/modules/*/', to: 'tests/modules/*/' }],
}

Selection per changed file, 2,400 app source files and 1,110 test files:

Typical file selects Files selecting 20 or fewer Files nothing reaches
Version 1 map all 1,079 0 884
Walk stops at packages 15 876 871
Plus a route resolver 9 1,262 408

The rest are env files and core models that most tests really do import, plus boot code.
A shared test-helper barrel still widens some files: following only the re-exports a
test imports would cut the p90 from 748 to 435 test files but leave the median at 15,
so it is not in this PR.

Pool sizing and run lock

  • --workers=auto and japa-agent size the pool from recorded durations of the planned
    files: one worker per ~10 s of test time, at most 8. A small selection stays in one
    process instead of paying for extra application boots.
  • japa-agent --workers=auto|N (default auto): one worker runs the command directly as
    before, more go through the coordinator.
  • A second japa-parallel in the same project waits for the first (withRunLock, stale
    when the owning process is gone). Worker N of two pools would otherwise share the app's
    database N. JAPA_PARALLEL_LOCK=0 opts out.
  • --update-baseline from a run of named suites (unit functional in an app that also
    has browser) now updates those suites' entries and leaves the others alone. Before,
    naming suites counted as a partial run and the baseline could never be written. The
    coordinator now says why it skipped a write.
  • japa-agent no longer mixes the previous-report delta into its output; the digest
    counts against the baseline (see below).

Fixes from running this in a large AdonisJS app

Integrating the branch into the app the benchmarks came from turned up these. Each has a
test that fails without the change.

Problem Change
--changed always ran the full suite on AdonisJS: providers and preloads load before configure(), so their imports were never recorded and the map never completed recordImportsFromStart(), called at the top of the test entrypoint, registers the resolve hook early
import type and specifiers inside JSDoc made recorded files look unrecorded The completeness check counts only imports Node resolves
--agent-file dropped every configured reporter, so app result files and guard reporters stopped silently Only --agent replaces reporters; --agent-file adds the agent reporter. japa-agent now uses --agent-file alone
Selection index reached 108 MB (a stamp per import per spec), and six workers queued on its lock at the end of a run One digest per entry, each path stat-ed once per write; parallel workers no longer write the index (1.1 MB after)
A worker that died was reported as "Unable to collect worker timings; use this runner fork" with its exit code replaced The message names the exit code or signal
config_manager and planner specs failed under any AI agent, because the default reporter becomes dot Expectations follow isRunningInAIAgent()
npm run x -- --workers=6 reaches the test command, not the coordinator JAPA_WORKERS env var for japa-parallel
A new spec could be skipped: a name guess picked a recorded spec with the same stem, and the plan kept only listed files. Without a guess, adding a spec ran the full suite The plan narrows only recorded files, and a changed unrecorded spec selects itself. A fast-check property test (random graphs, boot sets and change sets) checks that a narrowed selection always contains every test that reaches a changed path; it finds the bug in under 20 ms without the change
--changed fell back to the full suite with no explanation, and broken setups looked like a slow but working loop Warnings with the fix in each message, plus selection.fallbackReason in the agent summary

AdonisJS entrypoint:

import { configure, processCLIArgs, recordImportsFromStart, run } from '@japa/runner'

process.env.NODE_ENV = 'test'
recordImportsFromStart()

Controllers reached only through lazy route imports were the open case here; the
selection.resolvers hook above closes it (a controller change selects the specs that
request its routes).

Result in that app

Node 24.19, one 12-core machine, after the fixes above. Single runs, not medians.

Run Wall time Failed
Unit, serial (node ace test unit) 85 s 15
Unit, 6 workers 30-33 s 14-15, the same tests
Unit + functional (13,576 tests), serial ~360 s (81 + 280) 108
Unit + functional, --workers=auto (8) 120-148 s 94-117

Unit is stable in parallel. The functional suite's parallel-only failures vary from run to
run (10 to 33), and the causes found so far are in the application, not the runner: one
Mailpit inbox that every worker clears, Redis pub/sub channels (a cache bus, Transmit) that
ignore the per-worker Redis database, and tests that rely on rows accumulated in the
long-lived serial database. --allow-known-failures with a recorded baseline keeps those
from reading as new failures.

A typical --changed selection now runs about 14 test files (median over 2,246 source files, with selection.global set).

Fixes from an external review

A review of the branch reported 15 findings. Each fix below has a test that fails
without it, except the last row, which has no reliable reproduction.

Finding Change
A boot-loaded file that one test imports selected only that test, missing tests that depend on its boot effect selection.global: path prefixes whose change runs everything (config, bootstrap, route files). Treating every boot file this way would have run the full suite for 1,271 of 1,522 boot files in the app above, most of them services a module loads at boot
Stopping the walk at packages hid a project file a package loads Files a package imports after boot that no resolver names are recorded as bridges (map version 3); changing one runs everything
A resolver that threw was logged with debug and the map stayed complete It warns and marks the map incomplete
Completeness was sticky (latest.complete === true || ...) Only a complete run decides it, in either direction
selection.heuristic was read from the map, not the config Serial runs apply the current config; a parallel worker whose config differs from the map's runs its whole shard
Parallel --changed still dropped test files the map never recorded Workers get the recorded set too and keep everything else
The timing listener swallowed an unhandled rejection in global setup, so the run exited 0 Alone (before Japa's exceptions manager watches), it exits the way Node would
Merged parallel summaries: a dead worker vanished, processError was dropped, knownOnly was lost when another shard passed ok needs a summary from every worker, processError is kept, knownOnly means every failing shard failed on known tests
--failed-new matched baseline entries by title alone The retry cache also stores file + title; a title is skipped only when every failure under it is known for the same file
The selection signature hashed file paths only, so a different --tests filter could report a false "fixed" Title, group and tag filters are part of it
japa-agent on a pool dropped --update-baseline Forwarded, and a skipped write is reported
Auto worker sizing priced suites the command did not name Only the named suites are priced
A run-lock wait ignored cancellation; an empty lock file waited forever The wait is abortable, and an ownerless lock older than 5 s is removed
A late listener error could still promote timings and the baseline A process error after the worker report was written rewrites it as incomplete. Not reproduced: in the fixture the worker exits before a timer-delayed error fires

Two findings were partly declined. Counting # alias imports in the completeness check
flagged five files in the app whose value imports TypeScript erased (used only as types)
and made the whole map incomplete, so it was reverted and documented as a limit. Imports
inside test bodies are recorded by full runs only; --prepare-caches does not run tests.

Agent output

Measured on the AdonisJS app above with a throwaway failing unit spec. Before these
changes test:agent wrote 2.6 MB (36,717 lines) to the terminal: ANSI colours, each
worker's migrator and seeder logs, and Japa's own stack frames. An agent that reads its
command output paid for all of it.

Change Effect
japa-agent sends the command's output to <report>.log and prints a digest on stderr. --verbose streams the output as before Stdout empty, stderr 523 bytes for the same run
The digest names each failure to act on with file, line and message; with a baseline, only the new ones No need to open the report for the common case
A run that wrote no summary prints the last 30 lines of the log A setup crash is still visible
The summary's baseline.newFailures lists up to 20 failures the baseline does not know, merged across workers The report says which failures the change caused
Merged durationMs is the slowest worker's, not the sum 134 s wall no longer reads as 868 s
japa-agent: running on 2 workers
japa-agent: FAILED exit=1 · 2 tests, 1 failed (1 new, 0 known) · 8.0s
japa-agent: selection changed: 1 of 1300 test files
japa-agent: new failures:
  tests/unit/zz_agent_probe.spec.ts:5  agent probe › fails on a value
    expected { a: 1, b: [ 1, 2 ] } to deeply equal { a: 2, b: [ 1, 3 ] }
japa-agent: report=tests/results/agent.jsonl log=tests/results/agent.log

Three selection bugs turned up while measuring it, each with a test:

Problem Change
On a pool, a new spec ran the full suite (1,082 files, 2 m 14 s). The coordinator never loads the config, so it cannot tell a new spec from a source file nothing imports Those paths go to the workers as pending; a worker runs its whole shard only when one is not a configured test file. The same probe now runs 1 file in 8 s
In a monorepo, every modified file missed the map: git diff --name-only prints repository-root paths (apps/web/app/x.ts) while ls-files --others prints cwd-relative ones, so --changed=auto ran everything for any edit git diff --relative. Changes outside the app directory are no longer seen
--prepare-caches spent about 3.3 s per process in the completeness check, re-reading a shared file once per test that reached it (277,046 reads of 3,193 files) Each file is read and scanned once per write. --prepare-caches in the app went from 19 s to about 15 s; the map is still complete

Known limits

  • Dependency recording uses module.registerHooks. On older Node the map is unavailable and --changed runs the full suite.
  • Application boot remains the floor for targeted runs. In the AdonisJS app most of a 5.9 s one-file run was TypeScript loading: @poppinss/ts-exec transforms on the off-thread loader, and the per-import round trip took 2.2 s. An in-thread module.registerHooks loader with a content-hash cache took about 1.1 s off each process. That is an app-side change, not part of this PR.
  • The tsx loader showed a promising bootstrap reduction but has not been validated across the full suite.
  • package.json still advertises Node >=18.16. With ts-exec, the practical floor is 22.15; aligning engines is a release decision.
  • Parallel runs on real apps still need per-worker database/service isolation; the starter shared one SQLite file and could not run two workers.

Checks

  • npm run lint
  • npm run typecheck
  • npm run build
  • Runner tests 151/151 (npm test and quick:test), including a 2,000-run fast-check property for --changed (globals, bridges, policy)
  • Core tests 176/176, smoke 56 cases and the automatic workflow (at an earlier head, not rerun for the last five commits)

Scope

Runner source and tests only. No application source, credentials, benchmark experiment logs, lab harnesses, planning documents, or private artifacts are included. The branch is experimental and expected to be split or reworked before review.

Experimental. Adds an opt-in loop for running only the tests a change
can affect, with machine-readable output for agents and CI.

- Selection caches: --prepare-caches records which tests live in which
  file and which project modules each test file imports. Filters prune
  files before import.
- Change selection: --changed (or --changed=auto from git) runs only test
  files whose imports reach the edited paths, with a full-suite fallback.
- Agent reporting: --agent / --agent-file emit bounded JSON lines with
  locations, compaction, selection metadata and timings.
- japa-agent: prepares missing caches, runs changes, writes a delta and
  loop metrics.
- Baselines: --allow-known-failures and --update-baseline; the
  coordinator merges worker failures once.
- Automatic parallel coordination and timing history, with cache locking
  and --workers=auto for narrow selections.

Runs against the published @japa/core 10.4.0. Runner version is unchanged.
Checks: 109/109 tests, 176/176 core tests in the fork, lint and typecheck.
- japa-agent now resolves --cache-dir from either its own options or the
  forwarded command and uses it for cache detection, preparation and
  metrics. Previously it always checked the default directory and could
  prepare on every run.
- Remove the process-wide JAPA_SPEED_COLLECT_FAILURES mutation; the flag
  is passed per worker, so a later runParallel call does not inherit it.
- Remove unused overheadFor().
- Add AGENT_LOOP.md covering flags, exit codes, JSON output, AdonisJS
  usage, safety rules and limits; link it from the README.
- Drop duplicated runner tests uncovered during the port (109 -> 103).

Checks: 103/103 tests, lint (targeted), typecheck, tsdown and declarations.
…igure

AdonisJS resolves providers, preloads and config before configure() runs,
so the recorder never saw those modules' own imports and the dependency map
could never be marked complete. --changed then always ran the full suite.

recordImportsFromStart() registers the resolve hook early; recordDependencies
reuses its edges instead of registering a second hook.
…eteness

Type-only imports and specifiers inside JSDoc never reach Node's resolver,
so files that had them were reported as unrecorded and the dependency map
stayed incomplete on TypeScript apps.
--agent-file writes agent JSON to a file and leaves stdout alone, so there is
no mixed-output reason to drop the app's reporters. Apps that write their
own result files or run guard reporters keep them; --agent still replaces.
A worker that died mid-run was reported as 'Unable to collect worker
timings; use this runner fork', and its exit code was overwritten with 1.
Name the exit code or signal instead, and keep the misconfiguration hint
for reports that exist but cannot be parsed.
Each entry stored a size/mtime stamp for every file its spec imported. On an
app whose specs reach ~1,400 modules each, the index grew past 100 MB, every
write stat-ed each import once per spec, and six parallel workers queued on
the cache lock to rewrite it. Workers outlived the app's teardown exit timer
and died before writing their coordinator report.

Entries now keep one digest of their imports' stamps, computed from the
dependency map with each path stat-ed once per write. Parallel workers skip
the index write, as they already skip the dependency map.
The runner switches its default reporter to dot when isRunningInAIAgent()
is true, so the config and planner suites failed whenever an agent ran them.
japa-agent now writes its report through --agent-file only, so the command's
configured reporters still run. JAPA_WORKERS sets the worker count for
package scripts, whose extra arguments reach the test command instead.
--changed used to fall back to the full suite silently. It now says why,
worded as the fix (no map yet, an incomplete map and recordImportsFromStart(),
paths no spec imports, a Node without module.registerHooks), and the agent
summary carries it as selection.fallbackReason. --prepare-caches warns when
modules were imported before recording started, naming examples.

japa-parallel warns about more workers than CPUs, and a missing worker
report names the two causes seen in practice: a teardown process.exit()
timer and an app shutdown waiting on an unreleased connection.
A timed-out test that kept a transaction open was the cause in the app; the
teardown exit timer is a possible one, never observed. Order the message and
the docs that way.
…ze, run lock

The dependency walk no longer passes through packages. A framework package
loads most of an app at boot, so following those edges made every test depend
on every booted file and --changed always selected the whole suite. Project
files a package loads before the first test file are recorded as the boot set;
a change to one that no test reaches still runs everything.

selection.resolvers add files a test reaches without importing them (a
controller behind a route). selection.heuristic, off by default, guesses tests
by file name and prefix rules for paths nothing reaches, and the summary marks
the run as heuristic.

japa-agent and --workers=auto size the pool from recorded durations of the
planned files, so a small selection stays in one process. Concurrent pools in
one project now queue on a run lock instead of sharing worker resources.

The map format moves to version 2; version 1 maps are discarded.
Naming suites counted as a partial run, so an app with a browser suite could
never record a baseline from 'unit functional'. Entries of suites that did not
run now stay as they are, and the coordinator says why it skipped a write.

Also documents the selection rules, resolvers, heuristic and run lock.
The wrapper's new= count compares with the previous report, so a wide run
after a narrow one printed new=87 while the summary said one new failure
against the baseline. Print both, labelled.
A new spec changed alongside a heuristic match was dropped: the name guess
picked a recorded spec with the same stem, and the selection plan then kept
only listed files. A changed spec the map has not recorded now selects itself
(no full-suite fallback), and the plan restricts only recorded files, so a
spec pulled since the map was written still runs.

Adds fast-check as a dev dependency for a property test: over random graphs,
boot sets and change sets, a narrowed selection always contains every test
that reaches a changed path, and an exact one contains nothing else.
Selection could skip an affected test:
- selection.global: path prefixes whose change runs everything even when a
  test imports them (config, bootstrap, route files).
- Files a package loads after boot and no resolver names are recorded as
  bridges (map version 3); a change to one runs everything.
- A resolver that throws warns and marks the map incomplete.
- Only a complete run decides completeness, so it can be revoked.
- heuristic and global come from the current config; parallel workers whose
  config differs from the map's policy run their whole shard.
- Parallel runs keep test files the map never recorded, like serial runs.
- The completeness check counts `#` subpath imports.

Verdicts could read green or promote data wrongly:
- The timing listener no longer swallows an uncaught error before Japa's
  exceptions manager watches (global setup); a late error withdraws the
  worker report.
- Merged agent summaries need a summary from every worker, keep
  processError, and stay knownOnly when another shard passed.
- --failed-new skips a title only when every failure recorded under it is a
  baseline failure of the same file; the retry cache stores file + title.
- The selection signature includes title, group and tag filters.
- japa-agent pool runs forward --update-baseline and say when it was skipped.
- Auto worker sizing prices only the named suites.
- A run lock wait is abortable, and an empty lock file left by a dead
  process is recovered.
TypeScript erases a value import used only as a type, and alias imports are
where that is common: five such files in an AdonisJS app marked its whole map
incomplete, so --changed always ran the full suite.
A shared dependency was re-read and re-scanned for every test file that
reaches it: 277k visits over 3.2k unique files on a large app. Memoize the
per-file result for the duration of one write.
…s alone on a pool

- japa-agent writes the command's output to <report>.log and prints a digest on
  stderr: outcome, counts, selection, and the failures to act on with message and
  line. --verbose streams the output as before.
- The agent summary's baseline lists up to 20 newFailures; merged parallel
  summaries carry them and report the slowest worker's durationMs.
- The parallel coordinator has no config, so changed paths the map never saw are
  handed to workers as pending instead of forcing the full suite. A worker runs
  its whole shard only when one is not a configured test file.
git diff --name-only prints repository-root paths, so in a monorepo every
modified file under the app came through as apps/web/... and matched nothing,
and --changed=auto ran the full suite. --relative scopes the diff to the cwd,
matching what ls-files --others already printed.
An automatic pool could take 8 workers on a 12-core machine, each booting the app,
on top of the database those workers share. JAPA_MAX_WORKERS caps the automatic
count without forcing a pool for small selections the way JAPA_WORKERS does.
Workers now start at nice 10, so the rest of the machine stays responsive.

The digest log test relied on npm test's NODE_DEBUG to give the log content; it
now sets it itself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant