@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.
@@ -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 };