Architecture¶
Jest Runner is a single VS Code extension host process, bundled by Vite into dist/extension.js. It has no
background server and no long-running watcher of its own: it reacts to what you click, builds a command for
the test framework of that file, runs it, and (in the Test Explorer) reads the results back. This page gives the
big picture; the pages under Internals follow each part down to the functions.
The big picture¶
flowchart TB
subgraph UI["Entry points (VS Code UI)"]
CL["CodeLens<br/>TestRunnerCodeLensProvider"]
CMD["Commands & context menus<br/>extension.ts"]
TE["Test Explorer<br/>TestController"]
end
subgraph Detect["Test detection (src/testDetection)"]
TFC["testFileCache<br/>is this a test file?"]
FD["frameworkDetection<br/>which framework, which directory?"]
CP["configParsers<br/>static AST parsing of config files"]
PM["patternMatching<br/>globs and regexes"]
CM["CacheManager"]
end
subgraph Parse["Test file parsing (src/parsers)"]
JP["jestParser (Babel)<br/>describe / it / each"]
NP["nodeTestParser<br/>node:test"]
end
subgraph Build["Command building"]
TRC["TestRunnerConfig<br/>command, cwd, config path"]
FA["frameworkAdapters<br/>args per framework"]
TAB["TestArgumentBuilder<br/>batched args per framework"]
DCP["DebugConfigurationProvider<br/>launch config per framework"]
end
subgraph Run["Execution"]
TM["TerminalManager<br/>sendText to a terminal"]
TPR["TestProcessRunner<br/>spawn, stream, kill"]
DBG["vscode.debug.startDebugging"]
end
subgraph Results["Results & coverage"]
REP["reporters (injected)<br/>structured stdout protocol"]
TRP["testResultProcessor<br/>+ TestMatcher"]
OP["OutputParser / junitParser / tapParser"]
COV["coverageProvider<br/>+ lcov-parser"]
end
CL --> TFC
CL --> JP
CMD --> TRC
TE --> TFC
TE --> JP
TE --> NP
TFC --> FD --> CP --> PM
FD --> CM
TRC --> FD
CMD -->|TestRunner| FA --> TM
CMD -->|debug| DCP --> DBG
TE -->|TestRunExecutor| TAB --> TPR
TE -->|DebugHandler| DCP
TPR --> REP --> TRP
TPR --> OP --> TRP
TPR --> COV
TRC --> FA
TRC --> TAB
Read it top to bottom:
- Entry points decide what to run: a test name at the cursor, a file, a folder, or a selection of test items.
- Test detection decides which framework a file belongs to and from which directory it runs. It is the heart of the extension and the reason it works without settings.
- Test file parsing turns a file into a tree of
describeanditblocks with line numbers, for the CodeLens positions, the Test Explorer tree and the "test at the cursor" lookup. - Command building assembles the executable, the arguments and the working directory, once for the terminal (one test), once for the Test Explorer (many files, batched), and once for the debugger.
- Execution either types the command into a terminal, spawns a process and streams its output, or hands a launch configuration to VS Code.
- Results & coverage only exist for the Test Explorer: injected reporters write a structured block to stdout, which is matched back to the test items; coverage reports are converted for the VS Code coverage API.
Two run paths¶
The terminal path and the Test Explorer path share detection, parsing and configuration, but differ in how they run and what they know afterwards.
| Terminal path (CodeLens, commands) | Test Explorer path | |
|---|---|---|
| Orchestrator | TestRunner |
TestRunExecutor |
| Scope | one test, one file or one path | any selection across files, packages and frameworks |
| Arguments | frameworkAdapters via TestRunnerConfig.buildTestArgs |
TestArgumentBuilder strategies, which reuse the adapters for single-file runs |
| Quoting | for the shell of the integrated terminal (quoteArgsForTerminal) |
none: args go straight to spawn, except on Windows through cmd.exe |
| Process | TerminalManager sends the text to a reused terminal |
TestProcessRunner spawns, streams, enforces a buffer limit, kills the tree on cancel |
| Results | you read the terminal | reporters → testResultProcessor → run.passed() / run.failed() |
| Coverage | the framework writes its report | report is read back into the coverage view |
| Debug | DebugConfigurationProvider |
DebugConfigurationProvider (same) |
The debugger is the third path: both orchestrators call getDebugConfiguration(), which builds a VS Code
launch configuration with the same arguments that a run would use, and vscode.debug.startDebugging does
the rest.
What happens when you click "Run" on a test¶
sequenceDiagram
participant U as You
participant E as extension.ts
participant R as TestRunner
participant C as TestRunnerConfig
participant D as testDetection
participant A as frameworkAdapters
participant T as TerminalManager
U->>E: click CodeLens "Run" (argument: full test name pattern)
E->>R: runCurrentTest(testName)
R->>R: save the document
R->>C: getTestCommand(file)
C->>D: getTestFrameworkForFile(file)
D-->>C: 'vitest'
C-->>R: "node /proj/node_modules/vitest/vitest.mjs"
R->>C: buildTestArgs(file, testName, withQuotes=true)
C->>C: getVitestConfigPath(file) (only if configured)
C->>A: vitest adapter
A-->>R: ["run", "'/proj/src/a.test.ts'", "-t", "'^suite (?:> )?name$'"]
R->>R: quoteArgsForTerminal(args, vscode.env.shell)
R->>C: getTestRunCwd(file)
C-->>R: /proj/packages/web
R->>T: runCommand(command, {cwd, env, framework})
T->>T: reuse or create terminal "vitest"
T-->>U: terminal shows the run
The CodeLens already carries the test name as a regular expression: TestRunnerCodeLensProvider builds it
from the parsed tree (parent describe names joined with spaces, special characters escaped, %s and
$var placeholders replaced by (.*?), a describe extended with (\s[\s\S]*)? to match its children).
The adapter anchors it (^...$) and the framework-specific flag (-t, -g, --filter,
--test-name-pattern) carries it.
What happens when the Test Explorer runs a folder¶
sequenceDiagram
participant V as VS Code Testing API
participant X as TestRunExecutor
participant B as TestArgumentBuilder
participant P as TestProcessRunner
participant F as test framework process
participant M as testResultProcessor
V->>X: runHandler(request)
X->>X: collectTestsByFile(request) → Map<file, TestItem[]>
X->>X: createRunContexts(): group by framework, cwd, config, partial/full
loop one process per context
X->>B: buildTestArgs(files, testsByFile, framework, coverage)
B-->>X: args incl. injected reporter
X->>P: executeTestCommand(command, args, cwd, env {JSTR_SESSION_ID})
P->>F: spawn
F-->>P: stdout stream with @@JTR_START::<session>::results::<len>::{json}@@JTR_END::...
P->>M: processTestResultsFromParsed(json, tests, run)
M-->>V: run.passed / run.failed / run.skipped per item
P-->>X: {output, structuredResultsProcessed}
X->>X: no structured block? → JUnit / TAP / JSON fallback
X->>X: coverage? → coverageProvider → run.addCoverage()
end
X-->>V: run.end()
Design decisions¶
Per-file framework detection. There is no "project framework". Every file is classified on its own by
its imports first and its surroundings second. That is what makes a monorepo with Jest in one package and
Vitest in another, or a Playwright suite next to unit tests, work with no settings. The cost is a lot of file
system lookups, which CacheManager keeps cheap: directory-level results (does this directory use Jest?),
file-level results (framework and directory of this file) and config lookups are cached until a config file
or a setting changes.
Config files are parsed, not executed. Running a jest.config.ts would need the project's TypeScript
loader, its dependencies and its environment, inside the extension host. Instead parseUtils walks the Babel
AST and evaluates the subset that configs normally use. The trade-off is documented in
Config File Parsing; the escape hatch is jestrunner.disableFrameworkConfig.
Results come from injected reporters, not from parsing console output. Console output differs between
versions and reporters and is interleaved with the program's own logs. The extension writes three tiny
reporter files to the temp directory (jest-reporter.cjs, vitest-reporter.cjs, node-reporter.mjs) and
passes them on the command line. They emit one framed JSON block tagged with a per-run session id, so the
parser can find it in any amount of noise and never confuses two concurrent runs. Frameworks without a reporter
API produce JUnit XML (Rstest, Bun, Deno) or JSON (Playwright) instead. Details in
Reporters & Result Matching.
One shape for every result. All parsers produce the Jest --json shape (JestResults with
testResults[].assertionResults[]), so testResultProcessor and TestMatcher work for every framework.
Batching with care. A Test Explorer run of many files starts as few processes as possible, but a test name filter applies to the whole process. So files that are only partially selected are kept out of the process of fully selected files, and frameworks that cannot filter by file list on Windows are split in halves when the command line gets too long. See Command Building & Execution.
The terminal path trusts the shell, the spawn path avoids it. In the terminal, the user's shell is the
runtime, so arguments are quoted for that shell (PowerShell, cmd.exe, Git Bash, POSIX). When spawning, the
arguments go to the process directly and are unquoted first; only Windows .cmd shims force a cmd.exe in
between, with its own quoting rules.
Source layout¶
src/
├── extension.ts activation, commands, context keys, Test Explorer lifecycle
├── testRunner.ts terminal path orchestrator (run / debug / previous)
├── testRunnerConfig.ts commands, cwd, config paths, arg building facade
├── frameworkAdapters.ts per-framework CLI argument builders (single file)
├── TerminalManager.ts the reused terminal
├── TestRunnerCodeLensProvider.ts CodeLens actions incl. each-menus
├── TestController.ts Test Explorer controller: profiles, discovery refresh
├── testDiscovery.ts workspace scan and test item tree
├── testResultProcessor.ts results → TestRun states
├── testResultTypes.ts the JestResults shape
├── coverageProvider.ts coverage reports → VS Code coverage API
├── ConfigResolver.ts jestrunner.configPath / nearest config resolution
├── parser.ts chooses the test file parser
├── util.ts CodeLens option validation
├── cache/CacheManager.ts all detection caches
├── config/Settings.ts typed access to jestrunner.* settings
├── debug/DebugConfigurationProvider.ts launch configs per framework
├── execution/ Test Explorer run path
│ ├── TestArgumentBuilder.ts batched args per framework (strategies)
│ ├── TestCollector.ts request → tests by file, partial selection
│ └── TestProcessRunner.ts spawn, stream, buffer limit, kill tree
├── matchers/TestMatcher.ts result ↔ test item matching
├── parsers/ test files and runner output
│ ├── jestParser/ Babel-based describe/it/each parser
│ ├── nodeTestParser.ts node:test parser
│ ├── OutputParser.ts Jest / Vitest --json output
│ ├── junitParser.ts JUnit XML (Rstest, Bun, Deno)
│ ├── tapParser.ts TAP (Node.js fallback)
│ └── lcov-parser.ts LCOV (Node.js, Bun, Deno coverage)
├── reporters/ the injected reporters + where they are written
├── reporting/structuredOutput.ts the framing protocol of the reporters
├── testController/ Test Explorer helpers
│ ├── TestRunExecutor.ts run & coverage handler, grouping, Windows limits
│ ├── DebugHandler.ts debug profile
│ ├── TestFileWatcher.ts keeps the tree in sync with files
│ └── TestConfigWatcher.ts re-discovers on config / setting changes
├── testDetection/ framework & test file detection
│ ├── frameworkDefinitions.ts names, config files, binaries, default patterns
│ ├── frameworkDetection.ts which framework, which directory
│ ├── testFileDetection.ts which patterns, does the file match
│ ├── testFileCache.ts the cached "is test file" decision
│ ├── configParsing.ts config path lookup, custom config validation
│ ├── patternMatching.ts glob / regex matching, Jest-vs-Vitest tie break
│ └── configParsers/ jest, vitest, playwright, rstest, deno, cypress + parseUtils
├── utils/ paths, shells, args, test names, variables, logging
├── test/ unit tests (Jest) with a full vscode mock
└── e2e/ end-to-end tests with real Jest and Vitest processes
The Module Reference describes every file with its exports and its callers.