pi-lens 3.8.69 → 3.8.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +31 -13
- package/dist/clients/bootstrap.js +3 -1
- package/dist/clients/bus-events-logger.js +53 -0
- package/dist/clients/bus-publish.js +45 -2
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/code-quality-warnings.js +27 -0
- package/dist/clients/dead-code-client.js +5 -23
- package/dist/clients/diagnostic-dispositions.js +298 -0
- package/dist/clients/diagnostics-publish.js +38 -2
- package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/facts/function-facts.js +83 -0
- package/dist/clients/dispatch/integration.js +19 -4
- package/dist/clients/dispatch/rules/async-noise.js +1 -1
- package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
- package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
- package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
- package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
- package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
- package/dist/clients/dispatch/rules/high-complexity.js +12 -1
- package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
- package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
- package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
- package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
- package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
- package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
- package/dist/clients/dispatch/runners/lsp.js +23 -34
- package/dist/clients/dispatch/runners/oxlint.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +68 -0
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
- package/dist/clients/disposition-logger.js +39 -0
- package/dist/clients/disposition-publish.js +109 -0
- package/dist/clients/file-utils.js +94 -33
- package/dist/clients/format-events-publish.js +299 -0
- package/dist/clients/git-tracked-ignore.js +208 -0
- package/dist/clients/gitleaks-client.js +40 -5
- package/dist/clients/installer/index.js +123 -6
- package/dist/clients/instance-reaper.js +229 -2
- package/dist/clients/instance-registry.js +141 -2
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/knip-client.js +35 -38
- package/dist/clients/language-profile.js +11 -11
- package/dist/clients/lens-engine.js +12 -0
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/log-cleanup.js +61 -19
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/cascade-tier.js +31 -19
- package/dist/clients/lsp/client.js +124 -14
- package/dist/clients/lsp/config.js +27 -1
- package/dist/clients/lsp/index.js +1028 -68
- package/dist/clients/lsp/server-strategies.js +17 -8
- package/dist/clients/lsp/server.js +80 -28
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/lsp-budget.js +148 -0
- package/dist/clients/mcp/session.js +9 -0
- package/dist/clients/middle-man-analysis.js +260 -0
- package/dist/clients/module-report.js +173 -11
- package/dist/clients/ndjson-logger.js +24 -0
- package/dist/clients/opengrep-client.js +217 -0
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +66 -0
- package/dist/clients/pipeline.js +5 -1
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-diagnostics/extractors.js +21 -1
- package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
- package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/quiet-window.js +57 -2
- package/dist/clients/read-expansion.js +2 -3
- package/dist/clients/read-guard-tool-lines.js +10 -0
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/resource-sampler.js +254 -0
- package/dist/clients/review-graph/builder.js +322 -42
- package/dist/clients/review-graph/import-resolvers.js +53 -19
- package/dist/clients/review-graph/symbol-id.js +57 -0
- package/dist/clients/runtime-agent-end.js +26 -0
- package/dist/clients/runtime-coordinator.js +9 -1
- package/dist/clients/runtime-session.js +93 -17
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +15 -2
- package/dist/clients/runtime-turn.js +62 -13
- package/dist/clients/safe-spawn.js +48 -2
- package/dist/clients/session-state-store.js +6 -9
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +17 -25
- package/dist/clients/source-walker.js +66 -0
- package/dist/clients/startup-scan.js +105 -43
- package/dist/clients/symbol-containment.js +24 -0
- package/dist/clients/test-runner-client.js +544 -29
- package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/widget-state.js +83 -1
- package/dist/clients/write-ordering-guard.js +58 -0
- package/dist/clients/zizmor-config.js +29 -10
- package/dist/index.js +25511 -19414
- package/dist/mcp/server.js +95 -19
- package/dist/tools/activate-tools.js +72 -0
- package/dist/tools/ast-dump.js +2 -8
- package/dist/tools/lens-diagnostic-mark.js +158 -0
- package/dist/tools/lens-diagnostics.js +327 -33
- package/dist/tools/lsp-diagnostics.js +525 -105
- package/dist/tools/module-report.js +18 -5
- package/docs/agent-tools.md +136 -0
- package/docs/ast-grep_rules_catalog.md +564 -0
- package/docs/astplayground.md +178 -0
- package/docs/audit1.md +177 -0
- package/docs/custom-rules.md +231 -0
- package/docs/dependencies.md +54 -0
- package/docs/dispositions.md +85 -0
- package/docs/env_variables.md +10 -0
- package/docs/environment-variables.md +83 -0
- package/docs/fable.md +140 -0
- package/docs/features.md +338 -0
- package/docs/globalconfig.md +79 -0
- package/docs/language-coverage.md +48 -0
- package/docs/lsp-capability-matrix.md +157 -0
- package/docs/lsp-latency-benchmark.md +169 -0
- package/docs/mcp.md +438 -0
- package/docs/module-report-read-symbol.md +258 -0
- package/docs/servercapabilities.md +118 -0
- package/docs/subagent-compat.md +281 -0
- package/docs/tools.md +16 -0
- package/docs/tools_improvement2.md +345 -0
- package/docs/tree-sitter_rules_catalog.md +296 -0
- package/docs/usage.md +129 -0
- package/docs/word-index.md +150 -0
- package/package.json +10 -1
- package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
- package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
- package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
- package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
- package/skills/pi-lens-ast-grep/SKILL.md +3 -1
- package/dist/clients/production-readiness.js +0 -493
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* Detects test files and runs them on write/edit to provide
|
|
5
5
|
* immediate test feedback to the AI agent.
|
|
6
6
|
*
|
|
7
|
-
* Supports: vitest, jest, pytest
|
|
7
|
+
* Supports: vitest, jest, pytest, go, cargo, dotnet, gradle, maven, rspec,
|
|
8
|
+
* minitest, phpunit, mix (extensible to more)
|
|
8
9
|
*
|
|
9
10
|
* Design: File-level targeted testing — only runs tests for the
|
|
10
11
|
* specific file being edited, not the entire suite.
|
|
11
12
|
*/
|
|
12
13
|
import * as fs from "node:fs";
|
|
13
14
|
import * as path from "node:path";
|
|
15
|
+
import { minimatch } from "./deps/minimatch.js";
|
|
16
|
+
import { detectFileRole } from "./file-role.js";
|
|
14
17
|
import { findGlobalBinary } from "./package-manager.js";
|
|
15
18
|
import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
|
|
16
19
|
// Source file → test file patterns (reverse lookup)
|
|
@@ -42,7 +45,24 @@ const SOURCE_TO_TEST_PATTERNS = [
|
|
|
42
45
|
},
|
|
43
46
|
{ ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] }, // Go tests are co-located
|
|
44
47
|
{ ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] }, // Rust: tests/ or #[test] in src
|
|
48
|
+
// PHPUnit convention: tests/ mirrors src/ with ClassNameTest.php naming
|
|
49
|
+
// (e.g. src/Foo/Bar.php -> tests/Foo/BarTest.php). Basename is already the
|
|
50
|
+
// class name (PHP files are named after their class), so no case transform
|
|
51
|
+
// is needed — the mirrored-directory search below handles the tests/ root.
|
|
52
|
+
{ ext: ".php", testExts: ["Test.php"], dirs: ["tests"] },
|
|
53
|
+
// ExUnit convention: test/ mirrors lib/ with a _test.exs suffix on the same
|
|
54
|
+
// basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
|
|
55
|
+
{ ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] },
|
|
45
56
|
];
|
|
57
|
+
// Bound for walking up parent directories to find a hoisted node_modules
|
|
58
|
+
// (monorepo workspaces) — deep enough for realistic nesting
|
|
59
|
+
// (repo/packages/scope/pkg-name), never unbounded to the filesystem root.
|
|
60
|
+
const MAX_NODE_MODULES_WALK_UP = 5;
|
|
61
|
+
// Bound for recursive descent into a Python test directory when the exact
|
|
62
|
+
// same-relative-subdir mirror doesn't match (e.g. tests/unit/ grouping by
|
|
63
|
+
// test type rather than mirroring source layout) — capped depth, never an
|
|
64
|
+
// unbounded walk of the whole tests tree.
|
|
65
|
+
const MAX_PYTEST_RECURSE_DEPTH = 3;
|
|
46
66
|
// --- Runner Detection ---
|
|
47
67
|
const RUNNERS = {
|
|
48
68
|
vitest: {
|
|
@@ -129,12 +149,35 @@ const RUNNERS = {
|
|
|
129
149
|
args: (testFile, _cwd) => ["-Itest", testFile],
|
|
130
150
|
parseJson: false,
|
|
131
151
|
},
|
|
152
|
+
phpunit: {
|
|
153
|
+
// phpunit.xml(.dist) is the strong signal; composer.json is checked for
|
|
154
|
+
// a require-dev dependency on phpunit/phpunit (see the special case in
|
|
155
|
+
// detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
|
|
156
|
+
// handling above).
|
|
157
|
+
configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
|
|
158
|
+
command: "phpunit",
|
|
159
|
+
args: (testFile, _cwd) => [testFile],
|
|
160
|
+
parseJson: false, // PHPUnit's default CLI output is text-based
|
|
161
|
+
},
|
|
162
|
+
mix: {
|
|
163
|
+
configFiles: ["mix.exs"],
|
|
164
|
+
command: "mix",
|
|
165
|
+
args: (testFile, _cwd) => ["test", testFile],
|
|
166
|
+
parseJson: false, // mix test's default output is text-based
|
|
167
|
+
},
|
|
132
168
|
};
|
|
133
169
|
// --- Client ---
|
|
134
170
|
export class TestRunnerClient {
|
|
135
171
|
log;
|
|
136
172
|
availableRunners = new Map();
|
|
137
173
|
failedTestsByRunner = new Map();
|
|
174
|
+
// Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
|
|
175
|
+
// plain text (never executed) and cached per cwd so the config file is
|
|
176
|
+
// only read/parsed once, not on every edit. `null` means "no config found
|
|
177
|
+
// or it couldn't be parsed in the simple shape we look for" — callers
|
|
178
|
+
// treat that as "no additional signal" and fall back to naming-convention
|
|
179
|
+
// detection only.
|
|
180
|
+
vitestTestGlobsCache = new Map();
|
|
138
181
|
constructor(verbose = false) {
|
|
139
182
|
this.log = verbose
|
|
140
183
|
? (msg) => console.error(`[test-runner] ${msg}`)
|
|
@@ -170,6 +213,22 @@ export class TestRunnerClient {
|
|
|
170
213
|
return false;
|
|
171
214
|
}
|
|
172
215
|
}
|
|
216
|
+
if (name === "phpunit" && cf === "composer.json") {
|
|
217
|
+
const composerPath = path.join(cwd, cf);
|
|
218
|
+
if (!fs.existsSync(composerPath))
|
|
219
|
+
return false;
|
|
220
|
+
try {
|
|
221
|
+
const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8"));
|
|
222
|
+
const allDeps = {
|
|
223
|
+
...composer.require,
|
|
224
|
+
...composer["require-dev"],
|
|
225
|
+
};
|
|
226
|
+
return Boolean(allDeps["phpunit/phpunit"]);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
173
232
|
return fs.existsSync(path.join(cwd, cf));
|
|
174
233
|
});
|
|
175
234
|
this.availableRunners.set(cacheKey, found);
|
|
@@ -206,17 +265,22 @@ export class TestRunnerClient {
|
|
|
206
265
|
void err;
|
|
207
266
|
// package.json parse error or file not found
|
|
208
267
|
}
|
|
209
|
-
// Priority 3: Check node_modules for installed packages
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
268
|
+
// Priority 3: Check node_modules for installed packages, including a
|
|
269
|
+
// hoisted monorepo layout where cwd is a workspace package (e.g.
|
|
270
|
+
// packages/foo) but the runner only lives in node_modules at the
|
|
271
|
+
// workspace root (npm/yarn/pnpm workspace hoisting). Walk up a
|
|
272
|
+
// bounded number of parent directories looking for a node_modules
|
|
273
|
+
// containing the package — never an unbounded walk to the
|
|
274
|
+
// filesystem root.
|
|
275
|
+
const hoistedVitest = this.findHoistedNodeModulesPackage(cwd, "vitest");
|
|
276
|
+
if (hoistedVitest) {
|
|
277
|
+
this.log(`Detected vitest in node_modules (${hoistedVitest})`);
|
|
278
|
+
return { runner: "vitest", config: RUNNERS.vitest };
|
|
279
|
+
}
|
|
280
|
+
const hoistedJest = this.findHoistedNodeModulesPackage(cwd, "jest");
|
|
281
|
+
if (hoistedJest) {
|
|
282
|
+
this.log(`Detected jest in node_modules (${hoistedJest})`);
|
|
283
|
+
return { runner: "jest", config: RUNNERS.jest };
|
|
220
284
|
}
|
|
221
285
|
for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
|
|
222
286
|
const config = RUNNERS[name];
|
|
@@ -257,6 +321,252 @@ export class TestRunnerClient {
|
|
|
257
321
|
}
|
|
258
322
|
return null;
|
|
259
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Walk up from `cwd` through parent directories looking for a
|
|
326
|
+
* `node_modules/<packageName>` — handles monorepo workspace hoisting
|
|
327
|
+
* (npm/yarn/pnpm), where a workspace package's own `node_modules` may
|
|
328
|
+
* not exist at all, with dependencies hoisted to the workspace root
|
|
329
|
+
* several directories up. Bounded by `MAX_NODE_MODULES_WALK_UP` levels
|
|
330
|
+
* and stops at the filesystem root — never an unbounded walk.
|
|
331
|
+
* Returns the `node_modules` directory where the package was found, or
|
|
332
|
+
* null if not found within the bound.
|
|
333
|
+
*/
|
|
334
|
+
findHoistedNodeModulesPackage(cwd, packageName) {
|
|
335
|
+
let dir = path.resolve(cwd);
|
|
336
|
+
for (let level = 0; level <= MAX_NODE_MODULES_WALK_UP; level++) {
|
|
337
|
+
const nodeModulesPath = path.join(dir, "node_modules");
|
|
338
|
+
if (fs.existsSync(path.join(nodeModulesPath, packageName))) {
|
|
339
|
+
return nodeModulesPath;
|
|
340
|
+
}
|
|
341
|
+
const parent = path.dirname(dir);
|
|
342
|
+
if (parent === dir)
|
|
343
|
+
break; // reached filesystem root
|
|
344
|
+
dir = parent;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Depth-bounded breadth-first search under `rootDir` for a pytest-style
|
|
350
|
+
* test file matching `pattern` (exact, e.g. `test_foo.py`) or the
|
|
351
|
+
* looser `test_*<basename>*.py` convention. Used as a last-resort
|
|
352
|
+
* fallback when a Python test suite groups tests by kind
|
|
353
|
+
* (`tests/unit/`, `tests/integration/`) instead of mirroring the
|
|
354
|
+
* source directory layout, so the exact-mirror candidates in
|
|
355
|
+
* `findTestFile` don't match. Bounded by `maxDepth` levels below
|
|
356
|
+
* `rootDir` and skips hidden directories and `__pycache__` — never an
|
|
357
|
+
* unbounded walk of the whole tests tree.
|
|
358
|
+
*/
|
|
359
|
+
findPytestMatchRecursive(rootDir, pattern, basename, maxDepth) {
|
|
360
|
+
const queue = [
|
|
361
|
+
{ dir: rootDir, depth: 0 },
|
|
362
|
+
];
|
|
363
|
+
while (queue.length > 0) {
|
|
364
|
+
const next = queue.shift();
|
|
365
|
+
if (!next)
|
|
366
|
+
break;
|
|
367
|
+
const { dir, depth } = next;
|
|
368
|
+
let entries;
|
|
369
|
+
try {
|
|
370
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
const fullPath = path.join(dir, entry.name);
|
|
377
|
+
if (entry.isFile()) {
|
|
378
|
+
if (entry.name === pattern ||
|
|
379
|
+
(entry.name.startsWith("test_") &&
|
|
380
|
+
entry.name.endsWith(".py") &&
|
|
381
|
+
entry.name.includes(basename))) {
|
|
382
|
+
return fullPath;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (entry.isDirectory() && depth < maxDepth) {
|
|
386
|
+
if (entry.name === "__pycache__" || entry.name.startsWith("."))
|
|
387
|
+
continue;
|
|
388
|
+
queue.push({ dir: fullPath, depth: depth + 1 });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Path of `dir` relative to `cwd`, using forward slashes, or null if `dir`
|
|
396
|
+
* is not inside `cwd` (e.g. resolves to `..` or an absolute path).
|
|
397
|
+
* Used to compute a mirrored test-tree subdirectory (e.g. `clients` for
|
|
398
|
+
* `clients/knip-client.ts`, so `tests/clients/knip-client.test.ts` is
|
|
399
|
+
* checked alongside the flat `tests/knip-client.test.ts` candidate).
|
|
400
|
+
*/
|
|
401
|
+
relativeSourceDir(sourceFilePath, cwd) {
|
|
402
|
+
const dir = path.dirname(sourceFilePath);
|
|
403
|
+
const relDir = path.relative(cwd, path.resolve(cwd, dir));
|
|
404
|
+
if (!relDir || relDir === "." || relDir.startsWith("..") || path.isAbsolute(relDir)) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
return relDir;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Best-effort, text-only scrape of a vitest config's `test.include` /
|
|
411
|
+
* `test.exclude` arrays. This deliberately does NOT execute the config
|
|
412
|
+
* file (that would mean loading arbitrary ESM/TS via Vite's config
|
|
413
|
+
* loader — too heavy for a per-edit hot path). It just looks for a
|
|
414
|
+
* simple `include: [ ... ]` / `exclude: [ ... ]` shape with string
|
|
415
|
+
* literals inside and pulls those out with a regex.
|
|
416
|
+
*
|
|
417
|
+
* Returns `null` (never throws) when there's no vitest config file, it
|
|
418
|
+
* can't be read, or the include/exclude shape isn't a plain array of
|
|
419
|
+
* string literals (e.g. it's built from a function call, spread, or
|
|
420
|
+
* template expression) — anything more dynamic than that is out of
|
|
421
|
+
* scope for this heuristic.
|
|
422
|
+
*
|
|
423
|
+
* Cached per `cwd` so the file is only read/parsed once per project,
|
|
424
|
+
* not on every edit.
|
|
425
|
+
*/
|
|
426
|
+
parseVitestTestGlobs(cwd) {
|
|
427
|
+
if (this.vitestTestGlobsCache.has(cwd)) {
|
|
428
|
+
return this.vitestTestGlobsCache.get(cwd) ?? null;
|
|
429
|
+
}
|
|
430
|
+
// .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
|
|
431
|
+
// *detection* priority) but is a legal vitest config extension, so it's
|
|
432
|
+
// included here for the scrape even though detectRunner doesn't check it.
|
|
433
|
+
const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
|
|
434
|
+
let content = null;
|
|
435
|
+
for (const cf of candidates) {
|
|
436
|
+
try {
|
|
437
|
+
content = fs.readFileSync(path.join(cwd, cf), "utf-8");
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
let result = null;
|
|
445
|
+
if (content !== null) {
|
|
446
|
+
const include = this.extractGlobArrayLiteral(content, "include");
|
|
447
|
+
const exclude = this.extractGlobArrayLiteral(content, "exclude");
|
|
448
|
+
if (include || exclude) {
|
|
449
|
+
result = {};
|
|
450
|
+
if (include)
|
|
451
|
+
result.include = include;
|
|
452
|
+
if (exclude)
|
|
453
|
+
result.exclude = exclude;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
this.vitestTestGlobsCache.set(cwd, result);
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Extract `<key>: [ 'a', "b", `c` ]` as a plain string array from raw
|
|
461
|
+
* config text. Returns undefined if the key isn't present, or if the
|
|
462
|
+
* array body contains anything besides string literals and commas/
|
|
463
|
+
* whitespace (a function call, spread, variable reference, etc.) —
|
|
464
|
+
* that's a sign the value is dynamic and this best-effort scrape can't
|
|
465
|
+
* safely interpret it.
|
|
466
|
+
*/
|
|
467
|
+
extractGlobArrayLiteral(content, key) {
|
|
468
|
+
const arrayMatch = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
|
|
469
|
+
if (!arrayMatch)
|
|
470
|
+
return undefined;
|
|
471
|
+
const body = arrayMatch[1];
|
|
472
|
+
const literalPattern = /'([^'\\]*)'|"([^"\\]*)"|`([^`\\]*)`/g;
|
|
473
|
+
const literals = [];
|
|
474
|
+
let lastEnd = 0;
|
|
475
|
+
let match;
|
|
476
|
+
while ((match = literalPattern.exec(body)) !== null) {
|
|
477
|
+
const between = body.slice(lastEnd, match.index).trim();
|
|
478
|
+
// Only whitespace/commas may appear between literals — anything
|
|
479
|
+
// else (identifiers, parens, spreads) means the array isn't a
|
|
480
|
+
// plain list of string literals.
|
|
481
|
+
if (between !== "" && !/^,$/.test(between))
|
|
482
|
+
return undefined;
|
|
483
|
+
literals.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
484
|
+
lastEnd = literalPattern.lastIndex;
|
|
485
|
+
}
|
|
486
|
+
const trailing = body.slice(lastEnd).trim();
|
|
487
|
+
if (trailing !== "" && trailing !== ",")
|
|
488
|
+
return undefined;
|
|
489
|
+
return literals.length > 0 ? literals : undefined;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Whether `sourceFilePath` is itself a test file (as opposed to a source
|
|
493
|
+
* file whose *related* test file needs to be discovered).
|
|
494
|
+
*
|
|
495
|
+
* Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
|
|
496
|
+
* basenames, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
|
|
497
|
+
* directories — shared with the rest of the codebase, not a second
|
|
498
|
+
* parallel detector).
|
|
499
|
+
*
|
|
500
|
+
* Secondary signal (vitest only): the project's own `test.include` /
|
|
501
|
+
* `test.exclude` globs, best-effort scraped by `parseVitestTestGlobs`.
|
|
502
|
+
* This can correct the naming-convention answer in both directions —
|
|
503
|
+
* an `exclude` glob can rule out a path that looks like a test by name,
|
|
504
|
+
* and an `include` glob can catch a project that puts tests somewhere
|
|
505
|
+
* unconventional. When no config is found or it can't be parsed, this
|
|
506
|
+
* is a no-op and behavior is unchanged.
|
|
507
|
+
*
|
|
508
|
+
* #628: a positive `include` override is only trusted when the glob is a
|
|
509
|
+
* *narrow* test signal (see `isNarrowTestGlob`) — a bare "any file with
|
|
510
|
+
* this extension" include (e.g. `src/**\/*.ts`) is common in real vitest
|
|
511
|
+
* configs and matches ordinary source files, so treating any match as
|
|
512
|
+
* "this is a test" produced vacuous `0p/0f` self-runs on plain source
|
|
513
|
+
* files (background-review.ts, index.ts, …). The `exclude` direction is
|
|
514
|
+
* left as a plain match: over-excluding only causes discovery to run on a
|
|
515
|
+
* file that's actually a test (falls back to `findTestFile`, not a false
|
|
516
|
+
* "self" positive), which is the safe failure mode.
|
|
517
|
+
*/
|
|
518
|
+
isTestFile(sourceFilePath, cwd, runner) {
|
|
519
|
+
let result = detectFileRole(sourceFilePath) === "test";
|
|
520
|
+
if (runner === "vitest") {
|
|
521
|
+
const globs = this.parseVitestTestGlobs(cwd);
|
|
522
|
+
if (globs) {
|
|
523
|
+
const rel = path
|
|
524
|
+
.relative(cwd, path.resolve(cwd, sourceFilePath))
|
|
525
|
+
.replace(/\\/g, "/");
|
|
526
|
+
const matches = (globs_, filter) => !!globs_?.some((g) => (!filter || filter(g)) && minimatch(rel, g, { dot: true }));
|
|
527
|
+
if (matches(globs.exclude)) {
|
|
528
|
+
result = false;
|
|
529
|
+
}
|
|
530
|
+
else if (!result &&
|
|
531
|
+
matches(globs.include, (g) => this.isNarrowTestGlob(g))) {
|
|
532
|
+
result = true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Whether an `include` glob is a specific enough signal to override a
|
|
540
|
+
* plain "this is source, not a test" naming-convention verdict (#628).
|
|
541
|
+
*
|
|
542
|
+
* Trusted when either:
|
|
543
|
+
* - a literal (non-wildcard) path segment before the first wildcard
|
|
544
|
+
* names a conventional test location (`tests/`, `test/`, `spec/`,
|
|
545
|
+
* `specs/`, `__tests__/`) — the real case this override exists for:
|
|
546
|
+
* a project whose test files live in such a directory without a
|
|
547
|
+
* `.test.`/`.spec.` name (e.g. `tests/**\/*.ts`).
|
|
548
|
+
* - the static suffix after the last wildcard encodes more than the
|
|
549
|
+
* bare language extension (e.g. `.check.ts`, `.flow.ts`) — an explicit
|
|
550
|
+
* project-specific naming convention, not "any file with this
|
|
551
|
+
* extension" (e.g. `**\/*.check.ts`).
|
|
552
|
+
*
|
|
553
|
+
* Rejected for a bare extension glob with no test-ish directory (e.g.
|
|
554
|
+
* `src/**\/*.ts`, `**\/*.ts`) — that shape matches every source file in
|
|
555
|
+
* the tree and is exactly what produced vacuous self-runs in practice.
|
|
556
|
+
*/
|
|
557
|
+
isNarrowTestGlob(glob) {
|
|
558
|
+
const testDirPattern = /^(tests?|specs?|__tests__)$/i;
|
|
559
|
+
for (const segment of glob.split("/")) {
|
|
560
|
+
if (segment.includes("*") || segment.includes("?"))
|
|
561
|
+
break;
|
|
562
|
+
if (testDirPattern.test(segment))
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
const lastWildcard = Math.max(glob.lastIndexOf("*"), glob.lastIndexOf("?"));
|
|
566
|
+
const suffix = lastWildcard >= 0 ? glob.slice(lastWildcard + 1) : glob;
|
|
567
|
+
const dotSegments = suffix.split(".").filter(Boolean);
|
|
568
|
+
return dotSegments.length >= 2;
|
|
569
|
+
}
|
|
260
570
|
/**
|
|
261
571
|
* Find test file for a given source file
|
|
262
572
|
* Returns the test file path if it exists, null otherwise
|
|
@@ -273,6 +583,12 @@ export class TestRunnerClient {
|
|
|
273
583
|
: this.detectRunner(cwd, sourceFilePath);
|
|
274
584
|
if (!detected)
|
|
275
585
|
return null;
|
|
586
|
+
// Relative subdirectory of the source file, used to check a mirrored
|
|
587
|
+
// test-tree layout (tests/<same-subdir>/<basename><testExt>), on top of
|
|
588
|
+
// the flat tests/<basename><testExt> layout already checked below.
|
|
589
|
+
// Null when the source file sits at the project root (dir === ".") or
|
|
590
|
+
// falls outside cwd — in that case there is no subdir to mirror.
|
|
591
|
+
const relDir = this.relativeSourceDir(sourceFilePath, cwd);
|
|
276
592
|
// Check each potential test file location
|
|
277
593
|
for (let i = 0; i < patterns.testExts.length; i++) {
|
|
278
594
|
const testExt = patterns.testExts[i];
|
|
@@ -280,23 +596,43 @@ export class TestRunnerClient {
|
|
|
280
596
|
// Handle glob patterns (pytest style: test_*.py)
|
|
281
597
|
if (testExt.includes("*")) {
|
|
282
598
|
const pattern = testExt.replace(/\*/g, basename);
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
599
|
+
const searchDirs = testDir === "."
|
|
600
|
+
? [dir]
|
|
601
|
+
: relDir
|
|
602
|
+
? [path.join(cwd, testDir, relDir), path.join(cwd, testDir)]
|
|
603
|
+
: [path.join(cwd, testDir)];
|
|
604
|
+
for (const searchDir of searchDirs) {
|
|
605
|
+
let files;
|
|
606
|
+
try {
|
|
607
|
+
files = fs.readdirSync(searchDir);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
void err;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const match = files.find((f) => f === pattern ||
|
|
614
|
+
(f.startsWith("test_") &&
|
|
615
|
+
f.endsWith(".py") &&
|
|
616
|
+
f.includes(basename)));
|
|
617
|
+
if (match) {
|
|
618
|
+
const testPath = path.join(searchDir, match);
|
|
619
|
+
this.log(`Found test file: ${testPath}`);
|
|
620
|
+
return { testFile: testPath, runner: detected.runner };
|
|
621
|
+
}
|
|
291
622
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
623
|
+
// None of the exact-mirror candidates matched. Python test
|
|
624
|
+
// suites commonly group tests by kind (tests/unit/,
|
|
625
|
+
// tests/integration/) rather than mirroring the source tree,
|
|
626
|
+
// so do a depth-bounded recursive search under the test
|
|
627
|
+
// root as a last resort before falling back to import
|
|
628
|
+
// scanning — bounded so a large repo can't turn this into
|
|
629
|
+
// an unbounded directory walk.
|
|
630
|
+
if (testDir !== ".") {
|
|
631
|
+
const recursiveMatch = this.findPytestMatchRecursive(path.join(cwd, testDir), pattern, basename, MAX_PYTEST_RECURSE_DEPTH);
|
|
632
|
+
if (recursiveMatch) {
|
|
633
|
+
this.log(`Found test file (recursive): ${recursiveMatch}`);
|
|
634
|
+
return { testFile: recursiveMatch, runner: detected.runner };
|
|
635
|
+
}
|
|
300
636
|
}
|
|
301
637
|
}
|
|
302
638
|
else {
|
|
@@ -305,8 +641,21 @@ export class TestRunnerClient {
|
|
|
305
641
|
const searchPaths = [
|
|
306
642
|
path.join(dir, testFilename), // same directory
|
|
307
643
|
path.join(dir, "__tests__", testFilename), // __tests__ subdirectory
|
|
644
|
+
...(relDir
|
|
645
|
+
? [
|
|
646
|
+
path.join(cwd, "tests", relDir, testFilename), // mirrored tests/<subdir>/
|
|
647
|
+
path.join(cwd, "__tests__", relDir, testFilename), // mirrored __tests__/<subdir>/
|
|
648
|
+
]
|
|
649
|
+
: []),
|
|
308
650
|
path.join(cwd, "tests", testFilename), // top-level tests/
|
|
309
651
|
path.join(cwd, "__tests__", testFilename), // top-level __tests__/
|
|
652
|
+
// PHP/Elixir-style source-root mirroring (e.g. src/Foo/Bar.php ->
|
|
653
|
+
// tests/Foo/BarTest.php, lib/accounts/user.ex ->
|
|
654
|
+
// test/accounts/user_test.exs): strips a conventional source-root
|
|
655
|
+
// segment and mirrors under this pattern's OWN configured test
|
|
656
|
+
// root (testDir), not the hardcoded "tests"/"__tests__" above —
|
|
657
|
+
// ExUnit's root is "test" (singular), which those don't cover.
|
|
658
|
+
...this.sourceRootMirroredCandidates(dir, cwd, testDir, testFilename),
|
|
310
659
|
];
|
|
311
660
|
for (const testPath of searchPaths) {
|
|
312
661
|
if (fs.existsSync(testPath)) {
|
|
@@ -336,7 +685,14 @@ export class TestRunnerClient {
|
|
|
336
685
|
return null;
|
|
337
686
|
const key = this.failedKey(cwd, detected.runner);
|
|
338
687
|
const failedSet = this.failedTestsByRunner.get(key);
|
|
339
|
-
|
|
688
|
+
// If the edited file is itself a test file, there's no "related test"
|
|
689
|
+
// to discover — running findTestFile on it would strip its own
|
|
690
|
+
// extension and search for nonsense like foo.test.test.ts. Skip
|
|
691
|
+
// discovery entirely and treat the file as its own target.
|
|
692
|
+
const selfIsTest = this.isTestFile(sourceFilePath, cwd, detected.runner);
|
|
693
|
+
const related = selfIsTest
|
|
694
|
+
? null
|
|
695
|
+
: this.findTestFile(sourceFilePath, cwd, detected.runner);
|
|
340
696
|
if (failedSet && failedSet.size > 0) {
|
|
341
697
|
if (related) {
|
|
342
698
|
const relatedAbs = path.resolve(related.testFile);
|
|
@@ -349,6 +705,17 @@ export class TestRunnerClient {
|
|
|
349
705
|
};
|
|
350
706
|
}
|
|
351
707
|
}
|
|
708
|
+
if (selfIsTest) {
|
|
709
|
+
const selfAbs = path.resolve(sourceFilePath);
|
|
710
|
+
if (failedSet.has(selfAbs)) {
|
|
711
|
+
return {
|
|
712
|
+
testFile: selfAbs,
|
|
713
|
+
runner: detected.runner,
|
|
714
|
+
config: detected.config,
|
|
715
|
+
strategy: "failed-first",
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
352
719
|
return {
|
|
353
720
|
testFile: [...failedSet][0],
|
|
354
721
|
runner: detected.runner,
|
|
@@ -356,6 +723,14 @@ export class TestRunnerClient {
|
|
|
356
723
|
strategy: "failed-first",
|
|
357
724
|
};
|
|
358
725
|
}
|
|
726
|
+
if (selfIsTest) {
|
|
727
|
+
return {
|
|
728
|
+
testFile: path.resolve(sourceFilePath),
|
|
729
|
+
runner: detected.runner,
|
|
730
|
+
config: detected.config,
|
|
731
|
+
strategy: "self",
|
|
732
|
+
};
|
|
733
|
+
}
|
|
359
734
|
if (!related)
|
|
360
735
|
return null;
|
|
361
736
|
return {
|
|
@@ -399,6 +774,12 @@ export class TestRunnerClient {
|
|
|
399
774
|
case "pytest":
|
|
400
775
|
parsed = this.parsePytestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, cwd, runner);
|
|
401
776
|
break;
|
|
777
|
+
case "phpunit":
|
|
778
|
+
parsed = this.parsePhpunitOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
|
|
779
|
+
break;
|
|
780
|
+
case "mix":
|
|
781
|
+
parsed = this.parseMixTestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
|
|
782
|
+
break;
|
|
402
783
|
default:
|
|
403
784
|
parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
|
|
404
785
|
break;
|
|
@@ -534,6 +915,99 @@ export class TestRunnerClient {
|
|
|
534
915
|
error: exitCode === 2 ? "Pytest configuration error" : undefined,
|
|
535
916
|
};
|
|
536
917
|
}
|
|
918
|
+
// --- PHPUnit Parser (text-based, default CLI output) ---
|
|
919
|
+
parsePhpunitOutput(stdout, stderr, exitCode, testFile, runner) {
|
|
920
|
+
const output = `${stdout}\n${stderr}`;
|
|
921
|
+
let passed = 0;
|
|
922
|
+
let failed = 0;
|
|
923
|
+
let skipped = 0;
|
|
924
|
+
// Success (or success-with-incomplete/skipped): "OK (12 tests, 34 assertions)"
|
|
925
|
+
const okMatch = output.match(/OK\s*\((\d+)\s+tests?,\s*\d+\s+assertions?\)/i);
|
|
926
|
+
if (okMatch) {
|
|
927
|
+
passed = Number.parseInt(okMatch[1], 10);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
// Failure summary: "Tests: 12, Assertions: 34, Errors: 1, Failures: 2, Skipped: 1."
|
|
931
|
+
const testsMatch = output.match(/Tests:\s*(\d+)/i);
|
|
932
|
+
const failuresMatch = output.match(/Failures:\s*(\d+)/i);
|
|
933
|
+
const errorsMatch = output.match(/Errors:\s*(\d+)/i);
|
|
934
|
+
const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
|
|
935
|
+
const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
|
|
936
|
+
const failures = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
|
|
937
|
+
const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
|
|
938
|
+
skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
|
|
939
|
+
failed = failures + errors;
|
|
940
|
+
passed = Math.max(0, total - failed - skipped);
|
|
941
|
+
}
|
|
942
|
+
// Individual failures: "1) Foo\BarTest::testSomething"
|
|
943
|
+
const failures = [];
|
|
944
|
+
const failureRegex = /^\d+\)\s+(\S+)/gm;
|
|
945
|
+
let match;
|
|
946
|
+
while ((match = failureRegex.exec(output)) !== null) {
|
|
947
|
+
failures.push({ name: match[1], message: match[1] });
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
file: testFile,
|
|
951
|
+
sourceFile: "",
|
|
952
|
+
runner,
|
|
953
|
+
passed,
|
|
954
|
+
failed,
|
|
955
|
+
skipped,
|
|
956
|
+
failures,
|
|
957
|
+
duration: 0,
|
|
958
|
+
error: exitCode !== 0 && passed === 0 && failed === 0
|
|
959
|
+
? "PHPUnit runner error"
|
|
960
|
+
: undefined,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
// --- mix test Parser (ExUnit, text-based, default CLI output) ---
|
|
964
|
+
parseMixTestOutput(stdout, stderr, exitCode, testFile, runner) {
|
|
965
|
+
const output = `${stdout}\n${stderr}`;
|
|
966
|
+
let passed = 0;
|
|
967
|
+
let failed = 0;
|
|
968
|
+
let skipped = 0;
|
|
969
|
+
let duration = 0;
|
|
970
|
+
// Summary: "3 tests, 1 failure" (optionally ", N excluded" / ", N skipped")
|
|
971
|
+
const summaryMatch = output.match(/(\d+)\s+tests?,\s*(\d+)\s+failures?(?:,\s*(\d+)\s+excluded)?(?:,\s*(\d+)\s+skipped)?/i);
|
|
972
|
+
if (summaryMatch) {
|
|
973
|
+
const total = Number.parseInt(summaryMatch[1], 10);
|
|
974
|
+
failed = Number.parseInt(summaryMatch[2], 10);
|
|
975
|
+
const excluded = summaryMatch[3] ? Number.parseInt(summaryMatch[3], 10) : 0;
|
|
976
|
+
const skippedCount = summaryMatch[4]
|
|
977
|
+
? Number.parseInt(summaryMatch[4], 10)
|
|
978
|
+
: 0;
|
|
979
|
+
skipped = excluded + skippedCount;
|
|
980
|
+
passed = Math.max(0, total - failed - skipped);
|
|
981
|
+
}
|
|
982
|
+
const durationMatch = output.match(/Finished in\s+([\d.]+)\s+seconds?/i);
|
|
983
|
+
if (durationMatch) {
|
|
984
|
+
duration = Number.parseFloat(durationMatch[1]) * 1000;
|
|
985
|
+
}
|
|
986
|
+
// Individual failures: " 1) test some behavior (MyModuleTest)"
|
|
987
|
+
const failures = [];
|
|
988
|
+
const failureRegex = /^\s*\d+\)\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
|
|
989
|
+
let match;
|
|
990
|
+
while ((match = failureRegex.exec(output)) !== null) {
|
|
991
|
+
failures.push({
|
|
992
|
+
name: match[1].trim(),
|
|
993
|
+
message: match[1].trim(),
|
|
994
|
+
location: match[2].trim(),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
file: testFile,
|
|
999
|
+
sourceFile: "",
|
|
1000
|
+
runner,
|
|
1001
|
+
passed,
|
|
1002
|
+
failed,
|
|
1003
|
+
skipped,
|
|
1004
|
+
failures,
|
|
1005
|
+
duration,
|
|
1006
|
+
error: exitCode !== 0 && passed === 0 && failed === 0
|
|
1007
|
+
? "mix test runner error"
|
|
1008
|
+
: undefined,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
537
1011
|
// --- Generic text parser for non-JSON runners ---
|
|
538
1012
|
parseGenericRunnerOutput(stdout, stderr, exitCode, testFile, runner) {
|
|
539
1013
|
const output = `${stdout}\n${stderr}`;
|
|
@@ -655,6 +1129,36 @@ export class TestRunnerClient {
|
|
|
655
1129
|
return output.trimEnd();
|
|
656
1130
|
}
|
|
657
1131
|
// --- Helpers ---
|
|
1132
|
+
/**
|
|
1133
|
+
* Additional mirrored-directory candidate for source trees whose test
|
|
1134
|
+
* tree mirrors the source tree under a *different*, conventional
|
|
1135
|
+
* source-root segment rather than the source file's full relative
|
|
1136
|
+
* directory — e.g. PHPUnit's `src/Foo/Bar.php` -> `tests/Foo/BarTest.php`
|
|
1137
|
+
* (strips `src`) or ExUnit's `lib/accounts/user.ex` ->
|
|
1138
|
+
* `test/accounts/user_test.exs` (strips `lib`).
|
|
1139
|
+
*
|
|
1140
|
+
* Unlike the `relDir`-based candidates above (which mirror under the
|
|
1141
|
+
* hardcoded "tests"/"__tests__" roots), this uses `testDir` — the
|
|
1142
|
+
* pattern's own configured test root from `SOURCE_TO_TEST_PATTERNS`
|
|
1143
|
+
* (e.g. "tests" for PHP, "test" for Elixir) — since ExUnit's root is
|
|
1144
|
+
* singular and wouldn't otherwise be checked.
|
|
1145
|
+
*
|
|
1146
|
+
* Returns an empty array when the source directory doesn't start with a
|
|
1147
|
+
* known source-root segment (src/lib/app) followed by at least one more
|
|
1148
|
+
* path segment — i.e. this is a no-op for languages/layouts that don't
|
|
1149
|
+
* use this convention.
|
|
1150
|
+
*/
|
|
1151
|
+
sourceRootMirroredCandidates(dir, cwd, testDir, testFilename) {
|
|
1152
|
+
const knownSourceRoots = new Set(["src", "lib", "app"]);
|
|
1153
|
+
const relDir = path.relative(cwd, dir);
|
|
1154
|
+
const segments = relDir.split(path.sep).filter(Boolean);
|
|
1155
|
+
if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
|
|
1156
|
+
return [
|
|
1157
|
+
path.join(cwd, testDir, ...segments.slice(1), testFilename),
|
|
1158
|
+
];
|
|
1159
|
+
}
|
|
1160
|
+
return [];
|
|
1161
|
+
}
|
|
658
1162
|
/**
|
|
659
1163
|
* Fallback discovery: scan known test directories for a file that imports
|
|
660
1164
|
* the source module. Catches cases where the test file name doesn't match
|
|
@@ -713,6 +1217,17 @@ export class TestRunnerClient {
|
|
|
713
1217
|
* is dropped since it becomes the command itself.
|
|
714
1218
|
*/
|
|
715
1219
|
async resolveExec(runner, config, testFile, cwd) {
|
|
1220
|
+
// PHPUnit has no npx-style automatic local-binary resolution — Composer's
|
|
1221
|
+
// standard local-install location is vendor/bin/phpunit, so check that
|
|
1222
|
+
// explicitly before falling back to a global `phpunit` on PATH.
|
|
1223
|
+
if (runner === "phpunit") {
|
|
1224
|
+
const suffix = process.platform === "win32" ? ".bat" : "";
|
|
1225
|
+
const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix}`);
|
|
1226
|
+
if (fs.existsSync(vendorBin)) {
|
|
1227
|
+
return { command: vendorBin, args: config.args(testFile, cwd) };
|
|
1228
|
+
}
|
|
1229
|
+
return { command: "phpunit", args: config.args(testFile, cwd) };
|
|
1230
|
+
}
|
|
716
1231
|
const binName = config.binName ?? runner;
|
|
717
1232
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
718
1233
|
const localBin = path.join(cwd, "node_modules", ".bin", binName + suffix);
|