@vitest-agent/plugin 1.0.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/LICENSE +21 -0
- package/README.md +50 -0
- package/index.d.ts +1216 -0
- package/index.js +31 -0
- package/layers/ConfigValidationLive.js +148 -0
- package/layers/ConfigValidationTest.js +16 -0
- package/layers/CoverageAnalyzerLive.js +138 -0
- package/layers/CoverageAnalyzerTest.js +15 -0
- package/layers/ReporterLive.js +21 -0
- package/package.json +65 -0
- package/plugin.js +329 -0
- package/reporter.js +1349 -0
- package/services/ConfigValidation.js +11 -0
- package/services/CoverageAnalyzer.js +11 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/build-module-info.js +62 -0
- package/utils/build-reporter-kit.js +49 -0
- package/utils/capture-env.js +23 -0
- package/utils/capture-settings.js +54 -0
- package/utils/classify-helpers.js +72 -0
- package/utils/discover-projects.js +68 -0
- package/utils/discover-strategy.js +158 -0
- package/utils/find-test-files.js +94 -0
- package/utils/inject-tags.js +94 -0
- package/utils/process-failure.js +89 -0
- package/utils/resolve-thresholds.js +65 -0
- package/utils/route-rendered-output.js +45 -0
- package/utils/stringify-failure-value.js +36 -0
- package/utils/strip-console-reporters.js +45 -0
- package/utils/tag.js +42 -0
- package/utils/to-posix-path.js +35 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/services/ConfigValidation.ts
|
|
4
|
+
/**
|
|
5
|
+
* Effect service for validating Vitest + plugin coverage configuration.
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
var ConfigValidation = class extends Context.Tag("vitest-agent/ConfigValidation")() {};
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { ConfigValidation };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/services/CoverageAnalyzer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Effect service for processing istanbul coverage maps into structured reports.
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
var CoverageAnalyzer = class extends Context.Tag("vitest-agent/CoverageAnalyzer")() {};
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { CoverageAnalyzer };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.58.9"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { toPosixPath } from "./to-posix-path.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/build-module-info.ts
|
|
6
|
+
const NOT_FOUND = {
|
|
7
|
+
packageName: "",
|
|
8
|
+
packagePath: ""
|
|
9
|
+
};
|
|
10
|
+
const cache = /* @__PURE__ */ new Map();
|
|
11
|
+
const resolvePackageInfo = (filePath) => {
|
|
12
|
+
let dir = dirname(filePath);
|
|
13
|
+
const visited = [];
|
|
14
|
+
while (true) {
|
|
15
|
+
const cached = cache.get(dir);
|
|
16
|
+
if (cached !== void 0) {
|
|
17
|
+
for (const v of visited) cache.set(v, cached);
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
visited.push(dir);
|
|
21
|
+
const pkgJsonPath = `${dir}/package.json`;
|
|
22
|
+
if (existsSync(pkgJsonPath)) try {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
24
|
+
const result = {
|
|
25
|
+
packageName: typeof pkg.name === "string" ? pkg.name : "",
|
|
26
|
+
packagePath: dir
|
|
27
|
+
};
|
|
28
|
+
for (const v of visited) cache.set(v, result);
|
|
29
|
+
return result;
|
|
30
|
+
} catch {}
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) {
|
|
33
|
+
for (const v of visited) cache.set(v, NOT_FOUND);
|
|
34
|
+
return NOT_FOUND;
|
|
35
|
+
}
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Build a {@link ModuleInfo} for the given file path by walking up the
|
|
41
|
+
* directory tree to locate the nearest `package.json`. Results are cached
|
|
42
|
+
* per directory so the walk runs at most once per workspace package across
|
|
43
|
+
* the whole test run.
|
|
44
|
+
*
|
|
45
|
+
* Query strings (Vite virtual module suffixes like `?v=1234`) are stripped
|
|
46
|
+
* before the walk so the cache key is always a clean filesystem path.
|
|
47
|
+
*/
|
|
48
|
+
const buildModuleInfo = (filePath) => {
|
|
49
|
+
const queryIndex = filePath.indexOf("?");
|
|
50
|
+
const cleanId = queryIndex === -1 ? filePath : filePath.slice(0, queryIndex);
|
|
51
|
+
const { packageName, packagePath } = resolvePackageInfo(cleanId);
|
|
52
|
+
return {
|
|
53
|
+
path: cleanId,
|
|
54
|
+
relativePath: toPosixPath(relative(process.cwd(), cleanId)),
|
|
55
|
+
filename: basename(cleanId),
|
|
56
|
+
packageName,
|
|
57
|
+
packagePath
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { buildModuleInfo };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { osc8 } from "@vitest-agent/sdk";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/build-reporter-kit.ts
|
|
4
|
+
const buildReporterKit = (input) => {
|
|
5
|
+
const consoleOutput = input.consoleMode === "silent" ? "silent" : "failures";
|
|
6
|
+
const githubSummary = input.githubActions;
|
|
7
|
+
const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
8
|
+
const config = {
|
|
9
|
+
executor: input.executor,
|
|
10
|
+
consoleMode: input.consoleMode,
|
|
11
|
+
mcp: input.mcp,
|
|
12
|
+
consoleOutput,
|
|
13
|
+
omitPassingTests: true,
|
|
14
|
+
coverageConsoleLimit: 10,
|
|
15
|
+
includeBareZero: false,
|
|
16
|
+
githubActions: input.githubActions,
|
|
17
|
+
githubSummary,
|
|
18
|
+
format: input.format,
|
|
19
|
+
detail: input.detail,
|
|
20
|
+
noColor: input.noColor,
|
|
21
|
+
coverageMode: input.coverageMode,
|
|
22
|
+
transport: input.transport,
|
|
23
|
+
...input.dbPath !== void 0 && { dbPath: input.dbPath },
|
|
24
|
+
...input.projectFilter !== void 0 && { projectFilter: input.projectFilter },
|
|
25
|
+
...githubSummaryFile !== void 0 && { githubSummaryFile },
|
|
26
|
+
...input.runCommand !== void 0 && { runCommand: input.runCommand },
|
|
27
|
+
...input.coverageThresholds !== void 0 && { coverageThresholds: input.coverageThresholds },
|
|
28
|
+
...input.coverageTargets !== void 0 && { coverageTargets: input.coverageTargets },
|
|
29
|
+
...input.passWithNoTests !== void 0 && { passWithNoTests: input.passWithNoTests }
|
|
30
|
+
};
|
|
31
|
+
const osc8Enabled = !input.noColor && (input.env === "terminal" || input.env === "agent-shell");
|
|
32
|
+
return {
|
|
33
|
+
config,
|
|
34
|
+
stdEnv: input.env,
|
|
35
|
+
stdOsc8: (url, label) => osc8(url, label, { enabled: osc8Enabled }),
|
|
36
|
+
...input.runEvents !== void 0 && { runEvents: input.runEvents }
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Normalize the result of {@link VitestAgentReporterFactory} to an array.
|
|
41
|
+
* The factory contract allows returning either a single reporter or an
|
|
42
|
+
* array of reporters; the plugin always works with the array form.
|
|
43
|
+
*/
|
|
44
|
+
const normalizeReporters = (result) => {
|
|
45
|
+
return Array.isArray(result) ? result : [result];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { buildReporterKit, normalizeReporters };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/utils/capture-env.ts
|
|
2
|
+
const ALWAYS_CAPTURE = [
|
|
3
|
+
"CI",
|
|
4
|
+
"NODE_ENV",
|
|
5
|
+
"VITEST_MODE"
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* Capture CI and GitHub Actions environment variables for persistence.
|
|
9
|
+
* @param env - The process environment record to read from
|
|
10
|
+
* @returns A filtered map of relevant environment variable keys and values
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
function captureEnvVars(env) {
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const key of ALWAYS_CAPTURE) if (env[key] !== void 0) result[key] = env[key];
|
|
16
|
+
if (env.GITHUB_ACTIONS) {
|
|
17
|
+
for (const [key, value] of Object.entries(env)) if (value !== void 0 && (key.startsWith("GITHUB_") || key.startsWith("RUNNER_"))) result[key] = value;
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { captureEnvVars };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/capture-settings.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extract a serializable settings snapshot from the resolved Vitest config.
|
|
6
|
+
* @param config - The resolved Vitest config record
|
|
7
|
+
* @param vitestVersion - The running Vitest version string
|
|
8
|
+
* @returns A `SettingsInput` ready for persistence
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
function captureSettings(config, vitestVersion) {
|
|
12
|
+
const pool = config.pool;
|
|
13
|
+
const environment = config.environment;
|
|
14
|
+
const testTimeout = config.testTimeout;
|
|
15
|
+
const hookTimeout = config.hookTimeout;
|
|
16
|
+
const slowTestThreshold = config.slowTestThreshold;
|
|
17
|
+
const maxConcurrency = config.maxConcurrency;
|
|
18
|
+
const maxWorkers = config.maxWorkers;
|
|
19
|
+
const isolate = config.isolate;
|
|
20
|
+
const bail = config.bail;
|
|
21
|
+
const globals = config.globals;
|
|
22
|
+
const fileParallelism = config.fileParallelism;
|
|
23
|
+
const sequenceSeed = config.sequence?.seed;
|
|
24
|
+
const coverageProvider = config.coverage?.provider;
|
|
25
|
+
return {
|
|
26
|
+
vitestVersion,
|
|
27
|
+
...pool !== void 0 && { pool },
|
|
28
|
+
...environment !== void 0 && { environment },
|
|
29
|
+
...testTimeout !== void 0 && { testTimeout },
|
|
30
|
+
...hookTimeout !== void 0 && { hookTimeout },
|
|
31
|
+
...slowTestThreshold !== void 0 && { slowTestThreshold },
|
|
32
|
+
...maxConcurrency !== void 0 && { maxConcurrency },
|
|
33
|
+
...maxWorkers !== void 0 && { maxWorkers },
|
|
34
|
+
...isolate !== void 0 && { isolate },
|
|
35
|
+
...bail !== void 0 && { bail },
|
|
36
|
+
...globals !== void 0 && { globals },
|
|
37
|
+
...fileParallelism !== void 0 && { fileParallelism },
|
|
38
|
+
...sequenceSeed !== void 0 && { sequenceSeed },
|
|
39
|
+
...coverageProvider !== void 0 && { coverageProvider }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute a stable SHA-256 hash of a settings record for change detection.
|
|
44
|
+
* @param settings - The settings record to hash (keys are sorted for stability)
|
|
45
|
+
* @returns A hex-encoded SHA-256 digest
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
function hashSettings(settings) {
|
|
49
|
+
const json = JSON.stringify(settings, Object.keys(settings).sort());
|
|
50
|
+
return createHash("sha256").update(json).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
export { captureSettings, hashSettings };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { toPosixPath } from "./to-posix-path.js";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/classify-helpers.ts
|
|
4
|
+
/**
|
|
5
|
+
* Creates a ClassifyFn that maps filename suffix patterns to tag arrays.
|
|
6
|
+
*
|
|
7
|
+
* Accepts two forms:
|
|
8
|
+
* - `Record<string, ReadonlyArray<string>>` — keys are exact suffix strings
|
|
9
|
+
* (e.g. ".int.test.ts"); matched via `String.prototype.endsWith` against
|
|
10
|
+
* `module.filename`.
|
|
11
|
+
* - `ReadonlyArray<readonly [RegExp, ReadonlyArray<string>]>` — each tuple is a
|
|
12
|
+
* `[pattern, tags]` pair; matched via `RegExp.test` against `module.filename`.
|
|
13
|
+
* First match wins.
|
|
14
|
+
*
|
|
15
|
+
* No match returns an empty array.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
function classifyByFilename(suffixMap) {
|
|
19
|
+
if (Array.isArray(suffixMap)) {
|
|
20
|
+
const entries = suffixMap;
|
|
21
|
+
return (ctx) => {
|
|
22
|
+
for (const [pattern, tags] of entries) if (pattern.test(ctx.module.filename)) return tags;
|
|
23
|
+
return [];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const entries = Object.entries(suffixMap);
|
|
27
|
+
return (ctx) => {
|
|
28
|
+
for (const [suffix, tags] of entries) if (ctx.module.filename.endsWith(suffix)) return tags;
|
|
29
|
+
return [];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Creates a ClassifyFn that maps directory segment paths to tag arrays.
|
|
34
|
+
*
|
|
35
|
+
* Keys are directory-segment paths (e.g. `__test__/integration`). A module
|
|
36
|
+
* matches when `module.relativePath` contains the segment with `/` boundaries.
|
|
37
|
+
* Key `"integration"` matches `"integration/foo.test.ts"` and
|
|
38
|
+
* `"src/integration/foo.test.ts"` but NOT `"my-integration-tests/foo.test.ts"`.
|
|
39
|
+
*
|
|
40
|
+
* No match returns `[]`.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
function classifyByDirectory(dirMap) {
|
|
44
|
+
const entries = Object.entries(dirMap);
|
|
45
|
+
return (ctx) => {
|
|
46
|
+
const rel = toPosixPath(ctx.module.relativePath);
|
|
47
|
+
for (const [segment, tags] of entries) if (rel === segment || rel.startsWith(`${segment}/`) || rel.endsWith(`/${segment}`) || rel.includes(`/${segment}/`)) return tags;
|
|
48
|
+
return [];
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Composes multiple `ClassifyFn` values into one. Each classifier is called with
|
|
53
|
+
* the same context; results are concatenated in order and deduplicated by tag
|
|
54
|
+
* name (first occurrence wins). An empty list returns a function that always
|
|
55
|
+
* returns `[]`.
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
function combineClassifiers(...fns) {
|
|
59
|
+
if (fns.length === 0) return (_ctx) => [];
|
|
60
|
+
return (ctx) => {
|
|
61
|
+
const seen = /* @__PURE__ */ new Set();
|
|
62
|
+
const result = [];
|
|
63
|
+
for (const fn of fns) for (const tag of fn(ctx)) if (!seen.has(tag)) {
|
|
64
|
+
seen.add(tag);
|
|
65
|
+
result.push(tag);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { classifyByDirectory, classifyByFilename, combineClassifiers };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { toPosixPath } from "./to-posix-path.js";
|
|
2
|
+
import { DefaultDiscoverStrategy } from "./discover-strategy.js";
|
|
3
|
+
import { isAbsolute, join, normalize, relative } from "node:path";
|
|
4
|
+
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "workspaces-effect";
|
|
5
|
+
|
|
6
|
+
//#region src/utils/discover-projects.ts
|
|
7
|
+
const _cache = /* @__PURE__ */ new Map();
|
|
8
|
+
/**
|
|
9
|
+
* Scan all workspace packages and additional entries through the active strategy and return projects + tags.
|
|
10
|
+
* @param options - Optional strategy, working directory, and extra project entries
|
|
11
|
+
* @returns Resolved projects and tag definitions
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
async function discoverProjects(options) {
|
|
15
|
+
const strategy = options?.strategy;
|
|
16
|
+
const cwd = options?.cwd;
|
|
17
|
+
const additionalEntries = options?.additionalEntries ?? [];
|
|
18
|
+
const root = findWorkspaceRootSync(cwd ?? process.cwd());
|
|
19
|
+
if (!root) throw new Error(`[vitest-agent] Could not find workspace root from ${cwd ?? process.cwd()}. Ensure a pnpm-workspace.yaml or package.json with "workspaces" exists.`);
|
|
20
|
+
const useCache = strategy === void 0 && additionalEntries.length === 0;
|
|
21
|
+
if (useCache) {
|
|
22
|
+
const cached = _cache.get(root);
|
|
23
|
+
if (cached) return cached;
|
|
24
|
+
}
|
|
25
|
+
const resolvedStrategy = strategy ?? new DefaultDiscoverStrategy();
|
|
26
|
+
const packages = getWorkspacePackagesSync(root);
|
|
27
|
+
const configs = [];
|
|
28
|
+
const workspaceNames = /* @__PURE__ */ new Set();
|
|
29
|
+
const workspacePaths = /* @__PURE__ */ new Set();
|
|
30
|
+
for (const pkg of packages) {
|
|
31
|
+
const config = await resolvedStrategy.buildProject({
|
|
32
|
+
name: pkg.name,
|
|
33
|
+
path: pkg.path,
|
|
34
|
+
relativePath: toPosixPath(pkg.relativePath),
|
|
35
|
+
workspaceRoot: root
|
|
36
|
+
});
|
|
37
|
+
if (config !== null) configs.push(config);
|
|
38
|
+
workspaceNames.add(pkg.name);
|
|
39
|
+
workspacePaths.add(normalize(pkg.path));
|
|
40
|
+
}
|
|
41
|
+
for (const entry of additionalEntries) {
|
|
42
|
+
const normPath = normalize(isAbsolute(entry.path) ? entry.path : join(root, entry.path));
|
|
43
|
+
if (workspaceNames.has(entry.name)) throw new Error(`[vitest-agent] .addProject() conflict: name "${entry.name}" already exists as a workspace package. Use a different name or omit the .addProject() call.`);
|
|
44
|
+
if (workspacePaths.has(normPath)) throw new Error(`[vitest-agent] .addProject() conflict: resolved path "${normPath}" already exists as a workspace package path. Remove the .addProject() call or adjust the path.`);
|
|
45
|
+
const relativePath = toPosixPath(relative(root, normPath));
|
|
46
|
+
const config = await resolvedStrategy.buildProject({
|
|
47
|
+
name: entry.name,
|
|
48
|
+
path: normPath,
|
|
49
|
+
relativePath,
|
|
50
|
+
workspaceRoot: root
|
|
51
|
+
});
|
|
52
|
+
if (config === null) {
|
|
53
|
+
const strategyName = resolvedStrategy.constructor.name;
|
|
54
|
+
throw new Error(`[vitest-agent] .addProject({ name: "${entry.name}", path: "${entry.path}" }) resolved to path "${normPath}" but ${strategyName} found no test files there. Ensure the directory contains test files matching the strategy's patterns.`);
|
|
55
|
+
}
|
|
56
|
+
configs.push(config);
|
|
57
|
+
}
|
|
58
|
+
const tags = [...resolvedStrategy.tagDefinitions];
|
|
59
|
+
const result = {
|
|
60
|
+
projects: configs.length > 0 ? configs : void 0,
|
|
61
|
+
tags
|
|
62
|
+
};
|
|
63
|
+
if (useCache) _cache.set(root, result);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
export { discoverProjects };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { findTestFiles } from "./find-test-files.js";
|
|
2
|
+
import { Tag } from "./tag.js";
|
|
3
|
+
import { join, sep } from "node:path";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
//#region src/utils/discover-strategy.ts
|
|
7
|
+
const SETUP_EXTS = [
|
|
8
|
+
"ts",
|
|
9
|
+
"tsx",
|
|
10
|
+
"js",
|
|
11
|
+
"jsx"
|
|
12
|
+
];
|
|
13
|
+
const TEST_DIR_HELPER_DIRS = [
|
|
14
|
+
"utils",
|
|
15
|
+
"fixtures",
|
|
16
|
+
"snapshots"
|
|
17
|
+
];
|
|
18
|
+
async function isDir(p) {
|
|
19
|
+
try {
|
|
20
|
+
return (await stat(p)).isDirectory();
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function isFile(p) {
|
|
26
|
+
try {
|
|
27
|
+
return (await stat(p)).isFile();
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function detectSetupFile(pkgPath) {
|
|
33
|
+
for (const ext of SETUP_EXTS) if (await isFile(join(pkgPath, `vitest.setup.${ext}`))) return `vitest.setup.${ext}`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Abstract base for workspace discovery strategies. Implement `buildProject` and
|
|
38
|
+
* `classify` to control which packages become Vitest projects and how their test
|
|
39
|
+
* files are tagged. Use `DiscoverStrategy.create` to build a concrete instance
|
|
40
|
+
* from plain functions, or extend `DefaultDiscoverStrategy` to layer on top of
|
|
41
|
+
* the built-in unit/int/e2e heuristics.
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
var DiscoverStrategy = class {
|
|
45
|
+
static create(options) {
|
|
46
|
+
return new ConcreteDiscoverStrategy(options.tags, [options.classify], [options.buildProject]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var ConcreteDiscoverStrategy = class ConcreteDiscoverStrategy extends DiscoverStrategy {
|
|
50
|
+
tags;
|
|
51
|
+
#classifyLayers;
|
|
52
|
+
#buildProjectLayers;
|
|
53
|
+
constructor(tags, classifyLayers, buildProjectLayers) {
|
|
54
|
+
super();
|
|
55
|
+
this.tags = tags;
|
|
56
|
+
this.#classifyLayers = classifyLayers;
|
|
57
|
+
this.#buildProjectLayers = buildProjectLayers;
|
|
58
|
+
}
|
|
59
|
+
get tagDefinitions() {
|
|
60
|
+
return this.tags.map((t) => t.definition);
|
|
61
|
+
}
|
|
62
|
+
classify(ctx) {
|
|
63
|
+
const baseLayer = this.#classifyLayers[0];
|
|
64
|
+
let inherited = baseLayer({
|
|
65
|
+
module: ctx.module,
|
|
66
|
+
tags: this.tags,
|
|
67
|
+
inherited: []
|
|
68
|
+
});
|
|
69
|
+
for (let i = 1; i < this.#classifyLayers.length; i++) {
|
|
70
|
+
const layer = this.#classifyLayers[i];
|
|
71
|
+
inherited = layer({
|
|
72
|
+
module: ctx.module,
|
|
73
|
+
tags: this.tags,
|
|
74
|
+
inherited
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return inherited;
|
|
78
|
+
}
|
|
79
|
+
async buildProject(input) {
|
|
80
|
+
const baseLayer = this.#buildProjectLayers[0];
|
|
81
|
+
let result = await baseLayer(input);
|
|
82
|
+
for (let i = 1; i < this.#buildProjectLayers.length; i++) {
|
|
83
|
+
const layer = this.#buildProjectLayers[i];
|
|
84
|
+
result = await layer(input, result);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
extend(options) {
|
|
89
|
+
const newTags = [...this.tags, ...options.additionalTags ?? []];
|
|
90
|
+
const newClassifyLayers = [...this.#classifyLayers];
|
|
91
|
+
if (options.classify) newClassifyLayers.push(options.classify);
|
|
92
|
+
const newBuildProjectLayers = [...this.#buildProjectLayers];
|
|
93
|
+
if (options.buildProject) newBuildProjectLayers.push(options.buildProject);
|
|
94
|
+
return new ConcreteDiscoverStrategy(newTags, newClassifyLayers, newBuildProjectLayers);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const DEFAULT_TAGS = [
|
|
98
|
+
Tag.make("unit"),
|
|
99
|
+
Tag.make("int", { timeout: 6e4 }),
|
|
100
|
+
Tag.make("e2e", {
|
|
101
|
+
timeout: 12e4,
|
|
102
|
+
retry: process.env.CI ? 2 : 0
|
|
103
|
+
})
|
|
104
|
+
];
|
|
105
|
+
const E2E_RE = /\.e2e\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
106
|
+
const INT_RE = /\.int\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
107
|
+
/**
|
|
108
|
+
* The built-in `DiscoverStrategy` used by `AgentPlugin.discover` when no custom
|
|
109
|
+
* strategy is supplied. Registers `unit`, `int` (60 s timeout), and `e2e`
|
|
110
|
+
* (120 s timeout, retry in CI) tags and classifies test files by filename suffix
|
|
111
|
+
* (`.int.test.*` → `"int"`, `.e2e.test.*` → `"e2e"`, everything else → `"unit"`).
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
var DefaultDiscoverStrategy = class extends DiscoverStrategy {
|
|
115
|
+
tags = DEFAULT_TAGS;
|
|
116
|
+
get tagDefinitions() {
|
|
117
|
+
return this.tags.map((t) => t.definition);
|
|
118
|
+
}
|
|
119
|
+
classify(ctx) {
|
|
120
|
+
if (E2E_RE.test(ctx.module.filename)) return ["e2e"];
|
|
121
|
+
if (INT_RE.test(ctx.module.filename)) return ["int"];
|
|
122
|
+
return ["unit"];
|
|
123
|
+
}
|
|
124
|
+
async buildProject(input) {
|
|
125
|
+
const hasTestDir = await isDir(join(input.path, "__test__"));
|
|
126
|
+
const srcPrefix = join(input.path, "src");
|
|
127
|
+
const testPrefix = join(input.path, "__test__");
|
|
128
|
+
const allFiles = await findTestFiles(input.path, ["src/**/*.{test,spec}.{ts,tsx,js,jsx}", "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"]);
|
|
129
|
+
if (allFiles.length === 0) return null;
|
|
130
|
+
const hasSrcTests = allFiles.some((f) => f.startsWith(`${srcPrefix}${sep}`) || f === srcPrefix);
|
|
131
|
+
const hasTestDirTests = allFiles.some((f) => f.startsWith(`${testPrefix}${sep}`) || f === testPrefix);
|
|
132
|
+
const include = [];
|
|
133
|
+
if (hasSrcTests) include.push(join(input.path, "src/**/*.{test,spec}.{ts,tsx,js,jsx}"));
|
|
134
|
+
if (hasTestDirTests) include.push(join(input.path, "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"));
|
|
135
|
+
const exclude = hasTestDir ? TEST_DIR_HELPER_DIRS.map((d) => join(input.path, `__test__/${d}/**`)) : void 0;
|
|
136
|
+
const setupFile = await detectSetupFile(input.path);
|
|
137
|
+
return {
|
|
138
|
+
extends: true,
|
|
139
|
+
test: {
|
|
140
|
+
name: input.name,
|
|
141
|
+
environment: "node",
|
|
142
|
+
include,
|
|
143
|
+
...exclude ? { exclude } : {},
|
|
144
|
+
...setupFile ? { setupFiles: [join(input.path, setupFile)] } : {}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
extend(options) {
|
|
149
|
+
return DiscoverStrategy.create({
|
|
150
|
+
tags: this.tags,
|
|
151
|
+
classify: (ctx) => this.classify(ctx),
|
|
152
|
+
buildProject: (input) => this.buildProject(input)
|
|
153
|
+
}).extend(options);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
//#endregion
|
|
158
|
+
export { DefaultDiscoverStrategy, DiscoverStrategy };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { toPosixPath } from "./to-posix-path.js";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/find-test-files.ts
|
|
6
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
7
|
+
"node_modules",
|
|
8
|
+
".git",
|
|
9
|
+
"dist"
|
|
10
|
+
]);
|
|
11
|
+
function globToRegex(pattern) {
|
|
12
|
+
const alts = expandBraces(pattern).map(toRegexFragment);
|
|
13
|
+
return new RegExp(`^(?:${alts.join("|")})$`);
|
|
14
|
+
}
|
|
15
|
+
/** Expands the FIRST brace group found in a pattern string. Recursive to handle nesting. */
|
|
16
|
+
function expandBraces(pattern) {
|
|
17
|
+
const open = pattern.indexOf("{");
|
|
18
|
+
if (open === -1) return [pattern];
|
|
19
|
+
const close = pattern.indexOf("}", open);
|
|
20
|
+
if (close === -1) return [pattern];
|
|
21
|
+
const prefix = pattern.slice(0, open);
|
|
22
|
+
const suffix = pattern.slice(close + 1);
|
|
23
|
+
const alternatives = pattern.slice(open + 1, close).split(",");
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const alt of alternatives) for (const expanded of expandBraces(`${prefix}${alt}${suffix}`)) results.push(expanded);
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
/** Converts a brace-free glob string into a regex fragment (no ^ or $). */
|
|
29
|
+
function toRegexFragment(glob) {
|
|
30
|
+
let result = "";
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < glob.length) {
|
|
33
|
+
const ch = glob[i];
|
|
34
|
+
if (ch === "*") if (glob[i + 1] === "*") {
|
|
35
|
+
result += "(?:.+/|)";
|
|
36
|
+
i += 2;
|
|
37
|
+
if (glob[i] === "/") i++;
|
|
38
|
+
} else {
|
|
39
|
+
result += "[^/]*";
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
else if (ch === "?") {
|
|
43
|
+
result += "[^/]";
|
|
44
|
+
i++;
|
|
45
|
+
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
46
|
+
result += `\\${ch}`;
|
|
47
|
+
i++;
|
|
48
|
+
} else {
|
|
49
|
+
result += ch;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Async file walker that returns matched absolute paths.
|
|
57
|
+
*
|
|
58
|
+
* Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
|
|
59
|
+
* and `dist` directories. Matches files against the supplied glob patterns
|
|
60
|
+
* relative to `dir` (e.g. `"src/**\/*.test.ts"`).
|
|
61
|
+
*
|
|
62
|
+
* Returns an empty array if `dir` does not exist or no files match.
|
|
63
|
+
* @param dir - Absolute path to the directory to walk
|
|
64
|
+
* @param patterns - Glob patterns to match against (relative to `dir`)
|
|
65
|
+
* @returns Absolute paths of matched test files
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
async function findTestFiles(dir, patterns) {
|
|
69
|
+
if (patterns.length === 0) return [];
|
|
70
|
+
const matchers = patterns.map(globToRegex);
|
|
71
|
+
const results = [];
|
|
72
|
+
await walkDir(dir, dir, matchers, results);
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
async function walkDir(root, dir, matchers, results) {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const ent of entries) {
|
|
83
|
+
if (SKIP_DIRS.has(ent.name)) continue;
|
|
84
|
+
const fullPath = join(dir, ent.name);
|
|
85
|
+
if (ent.isDirectory()) await walkDir(root, fullPath, matchers, results);
|
|
86
|
+
else if (ent.isFile()) {
|
|
87
|
+
const rel = toPosixPath(relative(root, fullPath));
|
|
88
|
+
if (matchers.some((re) => re.test(rel))) results.push(fullPath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
export { findTestFiles };
|