@vitest-agent/plugin 2.0.16 → 2.1.0
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/index.d.ts +10 -2
- package/package.json +5 -5
- package/plugin.js +1 -1
- package/reporter.js +112 -26
- package/utils/discover-projects.js +84 -24
- package/utils/discover-strategy.js +8 -13
- package/utils/find-test-files.js +11 -2
- package/utils/stringify-failure-value.js +5 -1
package/index.d.ts
CHANGED
|
@@ -976,8 +976,16 @@ declare function combineClassifiers(...fns: ReadonlyArray<ClassifyFn>): Classify
|
|
|
976
976
|
* Async file walker that returns matched absolute paths.
|
|
977
977
|
*
|
|
978
978
|
* Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
|
|
979
|
-
* and `dist` directories
|
|
980
|
-
*
|
|
979
|
+
* and `dist` directories, and does not descend past a nested `package.json`
|
|
980
|
+
* boundary (any directory other than `dir` itself that has its own
|
|
981
|
+
* `package.json` is treated as an independent unit — its files belong to a
|
|
982
|
+
* separate discovery pass, not this one). The package-boundary rule applies
|
|
983
|
+
* to every supplied pattern, not just an unanchored `**\/` one — an anchored
|
|
984
|
+
* pattern like `"src/**\/*.test.ts"` will not match a test file under a
|
|
985
|
+
* nested package.json even though the pattern itself never reaches past
|
|
986
|
+
* `src/`, because the boundary check runs once per directory, independent of
|
|
987
|
+
* which pattern is being matched. Matches files against the supplied glob
|
|
988
|
+
* patterns relative to `dir` (e.g. `"src/**\/*.test.ts"`).
|
|
981
989
|
*
|
|
982
990
|
* Returns an empty array if `dir` does not exist or no files match.
|
|
983
991
|
* @param dir - Absolute path to the directory to walk
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/plugin",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
|
|
6
6
|
"keywords": [
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"@effect/platform-node": "4.0.0-beta.107",
|
|
43
43
|
"@effect/sql-sqlite-node": "4.0.0-beta.107",
|
|
44
44
|
"@effected/workspaces": "^0.11.1",
|
|
45
|
-
"@vitest-agent/cli": "2.0.
|
|
46
|
-
"@vitest-agent/mcp": "2.1.
|
|
47
|
-
"@vitest-agent/reporter": "2.0.
|
|
48
|
-
"@vitest-agent/sdk": "2.0
|
|
45
|
+
"@vitest-agent/cli": "2.0.17",
|
|
46
|
+
"@vitest-agent/mcp": "2.1.2",
|
|
47
|
+
"@vitest-agent/reporter": "2.0.17",
|
|
48
|
+
"@vitest-agent/sdk": "2.1.0",
|
|
49
49
|
"effect": "4.0.0-beta.107",
|
|
50
50
|
"magic-string": "^1.1.0"
|
|
51
51
|
},
|
package/plugin.js
CHANGED
|
@@ -98,7 +98,7 @@ const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
|
|
|
98
98
|
*
|
|
99
99
|
* @public
|
|
100
100
|
*/
|
|
101
|
-
const CURRENT_PLUGIN_VERSION = "2.0
|
|
101
|
+
const CURRENT_PLUGIN_VERSION = "2.1.0";
|
|
102
102
|
const TEST_FILE_SUFFIX_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/;
|
|
103
103
|
const TEST_FILE_DIR_RE = /\/(?:src|__test__)\//;
|
|
104
104
|
const isTestFile = (id) => TEST_FILE_SUFFIX_RE.test(id) && TEST_FILE_DIR_RE.test(id);
|
package/reporter.js
CHANGED
|
@@ -7,7 +7,7 @@ import { captureSettings, hashSettings } from "./utils/capture-settings.js";
|
|
|
7
7
|
import { processFailure } from "./utils/process-failure.js";
|
|
8
8
|
import { routeRenderedOutput } from "./utils/route-rendered-output.js";
|
|
9
9
|
import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
|
|
10
|
-
import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
|
|
10
|
+
import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, coerceErrorField, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
|
|
11
11
|
import { Effect, Option, PubSub } from "effect";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { mkdirSync } from "node:fs";
|
|
@@ -36,6 +36,21 @@ import { DefaultVitestAgentReporter } from "@vitest-agent/reporter";
|
|
|
36
36
|
* @internal
|
|
37
37
|
*/
|
|
38
38
|
const dbPathCache = /* @__PURE__ */ new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Safely read a raw Vitest error object's `stacks` array. The property
|
|
41
|
+
* access itself can throw on hostile shapes (live getters — the same
|
|
42
|
+
* ConfigError class of failure `coerceErrorField` guards); frames are
|
|
43
|
+
* dropped rather than crashing the persistence loop.
|
|
44
|
+
*/
|
|
45
|
+
function readErrorStacks(source) {
|
|
46
|
+
if (source === null || typeof source !== "object") return void 0;
|
|
47
|
+
try {
|
|
48
|
+
const value = source.stacks;
|
|
49
|
+
return Array.isArray(value) ? value : void 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
39
54
|
function resolveDataPathCached(projectDir) {
|
|
40
55
|
const cached = dbPathCache.get(projectDir);
|
|
41
56
|
if (cached !== void 0) return cached;
|
|
@@ -263,7 +278,9 @@ var AgentReporter = class {
|
|
|
263
278
|
*/
|
|
264
279
|
async onInit(vitest) {
|
|
265
280
|
this._vitest = vitest;
|
|
266
|
-
|
|
281
|
+
try {
|
|
282
|
+
await this.ensureDbPath();
|
|
283
|
+
} catch {}
|
|
267
284
|
await this.initReporters();
|
|
268
285
|
}
|
|
269
286
|
/**
|
|
@@ -801,7 +818,9 @@ var AgentReporter = class {
|
|
|
801
818
|
failCount: fail,
|
|
802
819
|
skipCount: skip,
|
|
803
820
|
timeoutCount: timeout,
|
|
804
|
-
durationMs: totalDuration
|
|
821
|
+
durationMs: totalDuration,
|
|
822
|
+
// @vitest-agent/ui, which both populate `collectedModules` on their
|
|
823
|
+
collectedModules: testModules.length
|
|
805
824
|
});
|
|
806
825
|
}
|
|
807
826
|
const modules = testModules;
|
|
@@ -818,11 +837,11 @@ var AgentReporter = class {
|
|
|
818
837
|
};
|
|
819
838
|
const wantsRunEvents = this.wantsRunEvents();
|
|
820
839
|
let dbPath;
|
|
840
|
+
let persistDisabled;
|
|
821
841
|
try {
|
|
822
842
|
dbPath = await this.ensureDbPath();
|
|
823
843
|
} catch (err) {
|
|
824
|
-
|
|
825
|
-
return;
|
|
844
|
+
persistDisabled = formatFatalError(err);
|
|
826
845
|
}
|
|
827
846
|
const filteredModules = opts.projectFilter ? modules.filter((m) => (m.project.name || "default") === opts.projectFilter) : modules;
|
|
828
847
|
if (filteredModules.length === 0 && opts.projectFilter) return;
|
|
@@ -886,14 +905,33 @@ var AgentReporter = class {
|
|
|
886
905
|
});
|
|
887
906
|
return;
|
|
888
907
|
}
|
|
889
|
-
|
|
908
|
+
if (dbPath !== void 0 && persistDisabled === void 0) try {
|
|
909
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
910
|
+
} catch (err) {
|
|
911
|
+
persistDisabled = formatFatalError(err);
|
|
912
|
+
}
|
|
913
|
+
let fallbackReports;
|
|
890
914
|
try {
|
|
891
|
-
|
|
915
|
+
const fallbackProjectGroups = /* @__PURE__ */ new Map();
|
|
916
|
+
for (const mod of filteredModules) {
|
|
917
|
+
const key = mod.project.name || "default";
|
|
918
|
+
const existing = fallbackProjectGroups.get(key);
|
|
919
|
+
if (existing) existing.push(mod);
|
|
920
|
+
else fallbackProjectGroups.set(key, [mod]);
|
|
921
|
+
}
|
|
922
|
+
const isMultiProjectFallback = fallbackProjectGroups.size > 1 || !!opts.projectFilter;
|
|
923
|
+
fallbackReports = [];
|
|
924
|
+
for (const [projectName, projectModules] of fallbackProjectGroups) fallbackReports.push(buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProjectFallback ? projectName : void 0));
|
|
892
925
|
} catch (err) {
|
|
893
926
|
process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
|
|
894
927
|
return;
|
|
895
928
|
}
|
|
896
|
-
|
|
929
|
+
if (dbPath !== void 0 && persistDisabled === void 0) try {
|
|
930
|
+
await ensureMigrated(dbPath, logLevel, logFile);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
persistDisabled = formatFatalError(err);
|
|
933
|
+
}
|
|
934
|
+
const persistProgram = Effect.gen(function* () {
|
|
897
935
|
const store = yield* DataStore;
|
|
898
936
|
const reader = yield* DataReader;
|
|
899
937
|
const analyzer = yield* CoverageAnalyzer;
|
|
@@ -977,6 +1015,7 @@ var AgentReporter = class {
|
|
|
977
1015
|
conversationId: null
|
|
978
1016
|
};
|
|
979
1017
|
const hostProbe = probeHostMetadataFromEnv(process.env);
|
|
1018
|
+
const projectReason = reason === "interrupted" ? "interrupted" : baseReport.summary.failed > 0 || baseReport.failedFiles.length > 0 ? "failed" : "passed";
|
|
980
1019
|
const runId = yield* store.writeRun({
|
|
981
1020
|
invocationId,
|
|
982
1021
|
project,
|
|
@@ -984,7 +1023,7 @@ var AgentReporter = class {
|
|
|
984
1023
|
timestamp: baseReport.timestamp,
|
|
985
1024
|
commitSha: process.env.GITHUB_SHA ?? null,
|
|
986
1025
|
branch: process.env.GITHUB_REF_NAME ?? null,
|
|
987
|
-
reason,
|
|
1026
|
+
reason: projectReason,
|
|
988
1027
|
duration: totalDuration,
|
|
989
1028
|
total: baseReport.summary.total,
|
|
990
1029
|
passed: baseReport.summary.passed,
|
|
@@ -1063,7 +1102,17 @@ var AgentReporter = class {
|
|
|
1063
1102
|
const inputs = [];
|
|
1064
1103
|
for (let ordinal = 0; ordinal < result.errors.length; ordinal++) {
|
|
1065
1104
|
const e = result.errors[ordinal];
|
|
1066
|
-
const
|
|
1105
|
+
const messageText = coerceErrorField(e, "message") ?? "<missing message>";
|
|
1106
|
+
const nameText = coerceErrorField(e, "name");
|
|
1107
|
+
const diffText = coerceErrorField(e, "diff");
|
|
1108
|
+
const stackText = coerceErrorField(e, "stack");
|
|
1109
|
+
const stacksValue = readErrorStacks(e);
|
|
1110
|
+
const { frames, signatureHash } = processFailure({
|
|
1111
|
+
message: messageText,
|
|
1112
|
+
...nameText !== void 0 && { name: nameText },
|
|
1113
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1114
|
+
...stacksValue !== void 0 && { stacks: stacksValue }
|
|
1115
|
+
});
|
|
1067
1116
|
if (signatureHash !== null) yield* store.writeFailureSignature({
|
|
1068
1117
|
signatureHash,
|
|
1069
1118
|
runId,
|
|
@@ -1072,10 +1121,10 @@ var AgentReporter = class {
|
|
|
1072
1121
|
inputs.push({
|
|
1073
1122
|
testCaseId,
|
|
1074
1123
|
scope: "test",
|
|
1075
|
-
message:
|
|
1076
|
-
...
|
|
1077
|
-
...
|
|
1078
|
-
...
|
|
1124
|
+
message: messageText,
|
|
1125
|
+
...nameText !== void 0 && { name: nameText },
|
|
1126
|
+
...diffText !== void 0 && { diff: diffText },
|
|
1127
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1079
1128
|
...signatureHash !== null && { signatureHash },
|
|
1080
1129
|
...frames.length > 0 && { frames },
|
|
1081
1130
|
ordinal
|
|
@@ -1090,7 +1139,16 @@ var AgentReporter = class {
|
|
|
1090
1139
|
const inputs = [];
|
|
1091
1140
|
for (let ordinal = 0; ordinal < modErrors.length; ordinal++) {
|
|
1092
1141
|
const e = modErrors[ordinal];
|
|
1093
|
-
const
|
|
1142
|
+
const messageText = coerceErrorField(e, "message") ?? "<missing message>";
|
|
1143
|
+
const nameText = coerceErrorField(e, "name");
|
|
1144
|
+
const stackText = coerceErrorField(e, "stack");
|
|
1145
|
+
const stacksValue = readErrorStacks(e);
|
|
1146
|
+
const { frames, signatureHash } = processFailure({
|
|
1147
|
+
message: messageText,
|
|
1148
|
+
...nameText !== void 0 && { name: nameText },
|
|
1149
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1150
|
+
...stacksValue !== void 0 && { stacks: stacksValue }
|
|
1151
|
+
});
|
|
1094
1152
|
if (signatureHash !== null) yield* store.writeFailureSignature({
|
|
1095
1153
|
signatureHash,
|
|
1096
1154
|
runId,
|
|
@@ -1099,9 +1157,9 @@ var AgentReporter = class {
|
|
|
1099
1157
|
inputs.push({
|
|
1100
1158
|
moduleId,
|
|
1101
1159
|
scope: "module",
|
|
1102
|
-
message:
|
|
1103
|
-
...
|
|
1104
|
-
...
|
|
1160
|
+
message: messageText,
|
|
1161
|
+
...nameText !== void 0 && { name: nameText },
|
|
1162
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1105
1163
|
...signatureHash !== null && { signatureHash },
|
|
1106
1164
|
...frames.length > 0 && { frames },
|
|
1107
1165
|
ordinal
|
|
@@ -1114,7 +1172,16 @@ var AgentReporter = class {
|
|
|
1114
1172
|
const inputs = [];
|
|
1115
1173
|
for (let ordinal = 0; ordinal < errors.length; ordinal++) {
|
|
1116
1174
|
const e = errors[ordinal];
|
|
1117
|
-
const
|
|
1175
|
+
const messageText = coerceErrorField(e, "message") ?? "<missing message>";
|
|
1176
|
+
const nameText = coerceErrorField(e, "name");
|
|
1177
|
+
const stackText = coerceErrorField(e, "stack");
|
|
1178
|
+
const stacksValue = readErrorStacks(e);
|
|
1179
|
+
const { frames, signatureHash } = processFailure({
|
|
1180
|
+
message: messageText,
|
|
1181
|
+
...nameText !== void 0 && { name: nameText },
|
|
1182
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1183
|
+
...stacksValue !== void 0 && { stacks: stacksValue }
|
|
1184
|
+
});
|
|
1118
1185
|
if (signatureHash !== null) yield* store.writeFailureSignature({
|
|
1119
1186
|
signatureHash,
|
|
1120
1187
|
runId,
|
|
@@ -1122,9 +1189,9 @@ var AgentReporter = class {
|
|
|
1122
1189
|
});
|
|
1123
1190
|
inputs.push({
|
|
1124
1191
|
scope: "unhandled",
|
|
1125
|
-
message:
|
|
1126
|
-
...
|
|
1127
|
-
...
|
|
1192
|
+
message: messageText,
|
|
1193
|
+
...nameText !== void 0 && { name: nameText },
|
|
1194
|
+
...stackText !== void 0 && { stack: stackText },
|
|
1128
1195
|
...signatureHash !== null && { signatureHash },
|
|
1129
1196
|
...frames.length > 0 && { frames },
|
|
1130
1197
|
ordinal
|
|
@@ -1155,7 +1222,7 @@ var AgentReporter = class {
|
|
|
1155
1222
|
const tcResult = tc.result();
|
|
1156
1223
|
if (tcResult?.state === "failed") {
|
|
1157
1224
|
const errors = tcResult.errors;
|
|
1158
|
-
errorMap.set(key, errors?.[0]
|
|
1225
|
+
errorMap.set(key, coerceErrorField(errors?.[0], "message") ?? null);
|
|
1159
1226
|
}
|
|
1160
1227
|
}
|
|
1161
1228
|
}
|
|
@@ -1291,10 +1358,29 @@ var AgentReporter = class {
|
|
|
1291
1358
|
direction: trendSummary.direction,
|
|
1292
1359
|
runCount: trendSummary.runCount
|
|
1293
1360
|
});
|
|
1361
|
+
const classifications = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const report of reports) for (const mod of report.failed) for (const test of mod.tests) if (test.classification) classifications.set(test.fullName, test.classification);
|
|
1294
1363
|
yield* Effect.logInfo("reports built").pipe(Effect.annotateLogs({
|
|
1295
1364
|
count: reports.length,
|
|
1296
1365
|
projects: Array.from(projectGroups.keys()).join(", ")
|
|
1297
1366
|
}));
|
|
1367
|
+
return {
|
|
1368
|
+
reports,
|
|
1369
|
+
classifications,
|
|
1370
|
+
...trendSummary !== void 0 && { trendSummary }
|
|
1371
|
+
};
|
|
1372
|
+
});
|
|
1373
|
+
let persistFailure;
|
|
1374
|
+
let persistResult;
|
|
1375
|
+
if (persistDisabled === void 0 && dbPath !== void 0) persistResult = await Effect.runPromise(persistProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive(dbPath, logLevel, logFile)))).catch((err) => {
|
|
1376
|
+
persistFailure = formatFatalError(err);
|
|
1377
|
+
});
|
|
1378
|
+
const renderInputData = persistResult ?? {
|
|
1379
|
+
reports: fallbackReports,
|
|
1380
|
+
classifications: /* @__PURE__ */ new Map()
|
|
1381
|
+
};
|
|
1382
|
+
const renderProgram = Effect.gen(function* () {
|
|
1383
|
+
const { reports, classifications, trendSummary } = renderInputData;
|
|
1298
1384
|
const detector = yield* EnvironmentDetector;
|
|
1299
1385
|
const executorResolver = yield* ExecutorResolver;
|
|
1300
1386
|
const formatSelector = yield* FormatSelector;
|
|
@@ -1316,8 +1402,6 @@ var AgentReporter = class {
|
|
|
1316
1402
|
format,
|
|
1317
1403
|
detail
|
|
1318
1404
|
}));
|
|
1319
|
-
const classifications = /* @__PURE__ */ new Map();
|
|
1320
|
-
for (const report of reports) for (const mod of report.failed) for (const test of mod.tests) if (test.classification) classifications.set(test.fullName, test.classification);
|
|
1321
1405
|
const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
1322
1406
|
const kit = buildReporterKit({
|
|
1323
1407
|
env,
|
|
@@ -1350,9 +1434,11 @@ var AgentReporter = class {
|
|
|
1350
1434
|
}));
|
|
1351
1435
|
for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
|
|
1352
1436
|
});
|
|
1353
|
-
await Effect.runPromise(
|
|
1437
|
+
await Effect.runPromise(renderProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
|
|
1354
1438
|
process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
|
|
1355
1439
|
});
|
|
1440
|
+
const persistError = persistDisabled ?? persistFailure;
|
|
1441
|
+
if (persistError !== void 0) process.stderr.write(`vitest-agent: persistence failed — results above were rendered but NOT recorded: ${persistError}\n`);
|
|
1356
1442
|
}
|
|
1357
1443
|
};
|
|
1358
1444
|
|
|
@@ -11,53 +11,113 @@ function recordDiscoveryScanTimestamp() {
|
|
|
11
11
|
globalThis[DISCOVERY_LAST_SCAN_SYMBOL] = (/* @__PURE__ */ new Date()).toISOString();
|
|
12
12
|
}
|
|
13
13
|
const _cache = /* @__PURE__ */ new Map();
|
|
14
|
+
const SIGNATURE_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
15
|
+
"node_modules",
|
|
16
|
+
".git",
|
|
17
|
+
"dist"
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Recursively finds every directory named `__test__` under `root` (issue
|
|
21
|
+
* #184: nested `__test__` dirs like `lib/scripts/__test__/` must invalidate
|
|
22
|
+
* the cache too, not just a package-root `__test__/`) and records every
|
|
23
|
+
* nested `package.json` (path + mtime) as a traversal-boundary marker. Skips
|
|
24
|
+
* `node_modules`, `.git`, and `dist` — see `SIGNATURE_SKIP_DIRS`. Stat-only:
|
|
25
|
+
* entries and mtimes are read, never file content. The root's own
|
|
26
|
+
* `package.json` is excluded — it is not a boundary for its own walk, and
|
|
27
|
+
* version bumps would otherwise churn the signature.
|
|
28
|
+
*/
|
|
29
|
+
async function findNestedTestDirs(root) {
|
|
30
|
+
const testDirs = [];
|
|
31
|
+
const boundaries = [];
|
|
32
|
+
await walkForTestDirs(root, root, testDirs, boundaries);
|
|
33
|
+
return {
|
|
34
|
+
testDirs: testDirs.sort(),
|
|
35
|
+
boundaries: boundaries.sort()
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function walkForTestDirs(root, dir, testDirs, boundaries) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const ent of entries) {
|
|
46
|
+
const fullPath = join(dir, ent.name);
|
|
47
|
+
if (ent.isDirectory()) {
|
|
48
|
+
if (SIGNATURE_SKIP_DIRS.has(ent.name)) continue;
|
|
49
|
+
if (ent.name === "__test__") testDirs.push(fullPath);
|
|
50
|
+
await walkForTestDirs(root, fullPath, testDirs, boundaries);
|
|
51
|
+
} else if (ent.isFile() && ent.name === "package.json" && dir !== root) try {
|
|
52
|
+
const mtimeMs = (await stat(fullPath)).mtimeMs;
|
|
53
|
+
boundaries.push(`${toPosixPath(relative(root, fullPath))}:${mtimeMs}`);
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
14
57
|
/**
|
|
15
58
|
* Computes a cheap signature (relative path + mtimeMs pairs, sorted) for every
|
|
16
59
|
* file nested under `dirPath`. Used to detect added/removed/moved/renamed test
|
|
17
60
|
* files between `discoverProjects` calls without re-walking test-file globs.
|
|
18
61
|
* Returns an empty string when `dirPath` does not exist — this still produces
|
|
19
62
|
* a stable, comparable signature contribution.
|
|
63
|
+
*
|
|
64
|
+
* Manual per-directory walk (not a single `readdir({ recursive: true })`
|
|
65
|
+
* call) pruning `SIGNATURE_SKIP_DIRS` *before* recursing — same reasoning as
|
|
66
|
+
* `findNestedTestDirs`: a found `__test__` dir can itself contain a fixture
|
|
67
|
+
* `node_modules` (e.g. a subprocess-e2e fixture that installs deps), and
|
|
68
|
+
* Node's recursive `readdir` follows symlinked directories, so an unguarded
|
|
69
|
+
* call would walk into it.
|
|
20
70
|
*/
|
|
21
71
|
async function computeDirSignature(dirPath) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
await walkDirSignature(dirPath, dirPath, parts);
|
|
74
|
+
parts.sort();
|
|
75
|
+
return parts.join("|");
|
|
76
|
+
}
|
|
77
|
+
async function walkDirSignature(root, dir, parts) {
|
|
22
78
|
let entries;
|
|
23
79
|
try {
|
|
24
|
-
entries = await readdir(
|
|
25
|
-
withFileTypes: true,
|
|
26
|
-
recursive: true
|
|
27
|
-
});
|
|
80
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
28
81
|
} catch {
|
|
29
|
-
return
|
|
82
|
+
return;
|
|
30
83
|
}
|
|
31
|
-
const parts = [];
|
|
32
84
|
for (const ent of entries) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
85
|
+
const fullPath = join(dir, ent.name);
|
|
86
|
+
if (ent.isDirectory()) {
|
|
87
|
+
if (SIGNATURE_SKIP_DIRS.has(ent.name)) continue;
|
|
88
|
+
await walkDirSignature(root, fullPath, parts);
|
|
89
|
+
} else if (ent.isFile()) {
|
|
90
|
+
let mtimeMs;
|
|
91
|
+
try {
|
|
92
|
+
mtimeMs = (await stat(fullPath)).mtimeMs;
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const relPath = toPosixPath(relative(root, fullPath));
|
|
97
|
+
parts.push(`${relPath}:${mtimeMs}`);
|
|
41
98
|
}
|
|
42
|
-
const relPath = toPosixPath(relative(dirPath, fullPath));
|
|
43
|
-
parts.push(`${relPath}:${mtimeMs}`);
|
|
44
99
|
}
|
|
45
|
-
parts.sort();
|
|
46
|
-
return parts.join("|");
|
|
47
100
|
}
|
|
48
101
|
/**
|
|
49
102
|
* Computes a cheap whole-workspace directory signature by combining each
|
|
50
|
-
* package's `src/`
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
103
|
+
* package's `src/` signature with every nested `__test__/` dir's signature
|
|
104
|
+
* (issue #100, extended by issue #184 to see `__test__` dirs at any depth,
|
|
105
|
+
* not just a package-root `__test__/`). Only entries + mtimes are read — no
|
|
106
|
+
* file content — so this stays fast even for large monorepos. A changed
|
|
107
|
+
* signature means a test file was added, removed, moved, or renamed since
|
|
108
|
+
* the cached result was computed.
|
|
54
109
|
*/
|
|
55
110
|
async function computeWorkspaceSignature(packages) {
|
|
56
111
|
const parts = [];
|
|
57
112
|
for (const pkg of packages) {
|
|
58
113
|
const srcSig = await computeDirSignature(join(pkg.path, "src"));
|
|
59
|
-
const
|
|
60
|
-
|
|
114
|
+
const { testDirs, boundaries } = await findNestedTestDirs(pkg.path);
|
|
115
|
+
const testDirParts = [];
|
|
116
|
+
for (const dir of testDirs) {
|
|
117
|
+
const sig = await computeDirSignature(dir);
|
|
118
|
+
testDirParts.push(`${toPosixPath(relative(pkg.path, dir))}=${sig}`);
|
|
119
|
+
}
|
|
120
|
+
parts.push(`${pkg.path}::src=${srcSig}::__test__=${testDirParts.join(";")}::boundaries=${boundaries.join(";")}`);
|
|
61
121
|
}
|
|
62
122
|
return parts.join("\n");
|
|
63
123
|
}
|
|
@@ -16,13 +16,6 @@ const TEST_DIR_HELPER_DIRS = [
|
|
|
16
16
|
"fixtures",
|
|
17
17
|
"snapshots"
|
|
18
18
|
];
|
|
19
|
-
async function isDir(p) {
|
|
20
|
-
try {
|
|
21
|
-
return (await stat(p)).isDirectory();
|
|
22
|
-
} catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
19
|
async function isFile(p) {
|
|
27
20
|
try {
|
|
28
21
|
return (await stat(p)).isFile();
|
|
@@ -123,17 +116,19 @@ var DefaultDiscoverStrategy = class extends DiscoverStrategy {
|
|
|
123
116
|
return ["unit"];
|
|
124
117
|
}
|
|
125
118
|
async buildProject(input) {
|
|
126
|
-
const hasTestDir = await isDir(join(input.path, "__test__"));
|
|
127
119
|
const srcPrefix = join(input.path, "src");
|
|
128
|
-
const
|
|
129
|
-
const allFiles = await findTestFiles(input.path, ["src/**/*.{test,spec}.{ts,tsx,js,jsx}", "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"]);
|
|
120
|
+
const allFiles = await findTestFiles(input.path, ["src/**/*.{test,spec}.{ts,tsx,js,jsx}", "**/__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"]);
|
|
130
121
|
if (allFiles.length === 0) return null;
|
|
131
122
|
const hasSrcTests = allFiles.some((f) => f.startsWith(`${srcPrefix}${sep}`) || f === srcPrefix);
|
|
132
|
-
const hasTestDirTests = allFiles.some((f) => f.startsWith(`${
|
|
123
|
+
const hasTestDirTests = allFiles.some((f) => !(f.startsWith(`${srcPrefix}${sep}`) || f === srcPrefix));
|
|
133
124
|
const include = [];
|
|
134
125
|
if (hasSrcTests) include.push(join(input.path, "src/**/*.{test,spec}.{ts,tsx,js,jsx}"));
|
|
135
|
-
if (hasTestDirTests) include.push(join(input.path, "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"));
|
|
136
|
-
const exclude =
|
|
126
|
+
if (hasTestDirTests) include.push(join(input.path, "**/__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"));
|
|
127
|
+
const exclude = hasTestDirTests ? [
|
|
128
|
+
...configDefaults.exclude,
|
|
129
|
+
join(input.path, "**/dist/**"),
|
|
130
|
+
...TEST_DIR_HELPER_DIRS.map((d) => join(input.path, `**/__test__/${d}/**`))
|
|
131
|
+
] : void 0;
|
|
137
132
|
const setupFile = await detectSetupFile(input.path);
|
|
138
133
|
return {
|
|
139
134
|
extends: true,
|
package/utils/find-test-files.js
CHANGED
|
@@ -57,8 +57,16 @@ function toRegexFragment(glob) {
|
|
|
57
57
|
* Async file walker that returns matched absolute paths.
|
|
58
58
|
*
|
|
59
59
|
* Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
|
|
60
|
-
* and `dist` directories
|
|
61
|
-
*
|
|
60
|
+
* and `dist` directories, and does not descend past a nested `package.json`
|
|
61
|
+
* boundary (any directory other than `dir` itself that has its own
|
|
62
|
+
* `package.json` is treated as an independent unit — its files belong to a
|
|
63
|
+
* separate discovery pass, not this one). The package-boundary rule applies
|
|
64
|
+
* to every supplied pattern, not just an unanchored `**\/` one — an anchored
|
|
65
|
+
* pattern like `"src/**\/*.test.ts"` will not match a test file under a
|
|
66
|
+
* nested package.json even though the pattern itself never reaches past
|
|
67
|
+
* `src/`, because the boundary check runs once per directory, independent of
|
|
68
|
+
* which pattern is being matched. Matches files against the supplied glob
|
|
69
|
+
* patterns relative to `dir` (e.g. `"src/**\/*.test.ts"`).
|
|
62
70
|
*
|
|
63
71
|
* Returns an empty array if `dir` does not exist or no files match.
|
|
64
72
|
* @param dir - Absolute path to the directory to walk
|
|
@@ -80,6 +88,7 @@ async function walkDir(root, dir, matchers, results) {
|
|
|
80
88
|
} catch {
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
91
|
+
if (dir !== root && entries.some((ent) => ent.isFile() && ent.name === "package.json")) return;
|
|
83
92
|
for (const ent of entries) {
|
|
84
93
|
if (SKIP_DIRS.has(ent.name)) continue;
|
|
85
94
|
const fullPath = join(dir, ent.name);
|