@savvy-web/vitest 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Savvy Web Strategy, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # @savvy-web/vitest
2
+
3
+ Automatic Vitest project discovery and configuration for pnpm monorepo
4
+ workspaces. Define your test config once at the root and let every workspace
5
+ package with a `src/` directory be discovered, classified, and configured
6
+ automatically.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pnpm add @savvy-web/vitest
12
+ ```
13
+
14
+ Peer dependencies: `vitest`, `@vitest/coverage-v8`
15
+
16
+ ## Quick Start
17
+
18
+ Create a single `vitest.config.ts` at your workspace root:
19
+
20
+ ```typescript
21
+ import { VitestConfig } from "@savvy-web/vitest";
22
+
23
+ export default VitestConfig.create(
24
+ ({ projects, coverage, reporters }) => ({
25
+ test: {
26
+ reporters,
27
+ projects: projects.map((p) => p.toConfig()),
28
+ coverage: { provider: "v8", ...coverage },
29
+ },
30
+ }),
31
+ );
32
+ ```
33
+
34
+ Every workspace package containing a `src/` directory is discovered and
35
+ configured. No per-package Vitest config files needed.
36
+
37
+ ## Manual Projects
38
+
39
+ When you need explicit control, use the factory methods directly:
40
+
41
+ ```typescript
42
+ import { VitestProject } from "@savvy-web/vitest";
43
+
44
+ const unit = VitestProject.unit({
45
+ name: "@savvy-web/my-lib",
46
+ include: ["src/**/*.test.ts"],
47
+ });
48
+
49
+ const e2e = VitestProject.e2e({
50
+ name: "@savvy-web/my-lib:e2e",
51
+ include: ["test/e2e/**/*.test.ts"],
52
+ overrides: {
53
+ test: { testTimeout: 60_000 },
54
+ },
55
+ });
56
+ ```
57
+
58
+ ## API Reference
59
+
60
+ ### `VitestConfig.create(callback, options?)`
61
+
62
+ Entry point for automatic workspace discovery. The callback receives:
63
+
64
+ - **`projects`** -- discovered `VitestProject[]` instances
65
+ - **`coverage`** -- `CoverageConfig` with include/exclude globs and thresholds
66
+ - **`reporters`** -- reporter names (adds `"github-actions"` in CI)
67
+ - **`isCI`** -- `true` when running in GitHub Actions
68
+
69
+ Returns whatever `ViteUserConfig` the callback produces (sync or async).
70
+
71
+ ### `VitestProject.unit(options)` / `.e2e(options)` / `.custom(kind, options)`
72
+
73
+ Factory methods that create projects with preset defaults:
74
+
75
+ | Factory | Environment | Test Timeout | Hook Timeout | Max Concurrency |
76
+ | --- | --- | --- | --- | --- |
77
+ | `unit()` | `"node"` | vitest default | vitest default | vitest default |
78
+ | `e2e()` | `"node"` | 120 s | 60 s | `floor(cpus / 2)` clamped 1--8 |
79
+ | `custom(kind)` | none | none | none | none |
80
+
81
+ ### `VitestProjectOptions`
82
+
83
+ ```typescript
84
+ interface VitestProjectOptions {
85
+ name: string;
86
+ include: string[];
87
+ kind?: VitestProjectKind;
88
+ overrides?: Partial<TestProjectInlineConfiguration>;
89
+ }
90
+ ```
91
+
92
+ `name` and `include` always take precedence over values in `overrides`.
93
+
94
+ ### `VitestConfigCreateOptions`
95
+
96
+ ```typescript
97
+ interface VitestConfigCreateOptions {
98
+ thresholds?: {
99
+ lines?: number; // default 80
100
+ functions?: number; // default 80
101
+ branches?: number; // default 80
102
+ statements?: number; // default 80
103
+ };
104
+ }
105
+ ```
106
+
107
+ ### `CoverageConfig`
108
+
109
+ ```typescript
110
+ interface CoverageConfig {
111
+ include: string[]; // source globs (e.g., "pkgs/my-lib/src/**/*.ts")
112
+ exclude: string[]; // test file globs
113
+ thresholds: {
114
+ lines: number;
115
+ functions: number;
116
+ branches: number;
117
+ statements: number;
118
+ };
119
+ }
120
+ ```
121
+
122
+ ## Test Discovery Conventions
123
+
124
+ ### Filename Patterns
125
+
126
+ | Pattern | Kind |
127
+ | --- | --- |
128
+ | `*.test.ts` / `*.spec.ts` | unit |
129
+ | `*.e2e.test.ts` / `*.e2e.spec.ts` | e2e |
130
+
131
+ ### Directory Scanning
132
+
133
+ Each workspace package is scanned if it contains a `src/` directory. An
134
+ optional `__test__/` directory at the package root is also included when
135
+ present.
136
+
137
+ ### Naming Suffixes
138
+
139
+ When a package has both unit and e2e test files, projects are automatically
140
+ suffixed with `:unit` and `:e2e` (e.g., `@savvy-web/my-lib:unit`). Packages
141
+ with only one kind use the bare package name.
142
+
143
+ ## CI Integration
144
+
145
+ When the `GITHUB_ACTIONS` environment variable is set, `VitestConfig.create`
146
+ automatically adds the `"github-actions"` reporter and sets `isCI: true` in the
147
+ callback. No additional configuration required.
148
+
149
+ ## License
150
+
151
+ [MIT](./LICENSE)
package/index.d.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Vitest utility functions for automatic project configuration discovery
3
+ * in pnpm monorepo workspaces.
4
+ *
5
+ * @remarks
6
+ * This package provides two main classes:
7
+ *
8
+ * - {@link VitestProject} - Represents a single Vitest project with sensible
9
+ * defaults per test kind (unit, e2e, or custom).
10
+ *
11
+ * - {@link VitestConfig} - Orchestrates workspace discovery, coverage
12
+ * configuration, reporter selection, and callback invocation.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { VitestConfig } from "@savvy-web/vitest";
17
+ *
18
+ * export default VitestConfig.create(
19
+ * ({ projects, coverage, reporters }) => ({
20
+ * test: {
21
+ * reporters,
22
+ * projects: projects.map((p) => p.toConfig()),
23
+ * coverage: { provider: "v8", ...coverage },
24
+ * },
25
+ * }),
26
+ * );
27
+ * ```
28
+ *
29
+ * @packageDocumentation
30
+ */
31
+
32
+ import { TestProjectInlineConfiguration } from 'vitest/config';
33
+ import type { ViteUserConfig } from 'vitest/config';
34
+
35
+ /**
36
+ * Coverage configuration passed to the {@link VitestConfigCallback}.
37
+ *
38
+ * @see {@link VitestConfig.create} for how this is generated
39
+ *
40
+ * @public
41
+ */
42
+ export declare interface CoverageConfig {
43
+ /** Glob patterns for files to include in coverage reporting. */
44
+ include: string[];
45
+ /** Glob patterns for files to exclude from coverage reporting. */
46
+ exclude: string[];
47
+ /** Resolved coverage thresholds with all metrics populated. */
48
+ thresholds: {
49
+ /** Minimum line coverage percentage. */
50
+ lines: number;
51
+ /** Minimum function coverage percentage. */
52
+ functions: number;
53
+ /** Minimum branch coverage percentage. */
54
+ branches: number;
55
+ /** Minimum statement coverage percentage. */
56
+ statements: number;
57
+ };
58
+ }
59
+
60
+ export { TestProjectInlineConfiguration }
61
+
62
+ /**
63
+ * Utility class for generating Vitest configuration in monorepo workspaces.
64
+ *
65
+ * @remarks
66
+ * This class automatically discovers packages in a workspace that contain a
67
+ * `src/` directory and generates appropriate {@link VitestProject} configurations.
68
+ * Tests are discovered by filename convention:
69
+ *
70
+ * | Pattern | Kind |
71
+ * | --- | --- |
72
+ * | `*.test.ts` / `*.spec.ts` | unit |
73
+ * | `*.e2e.test.ts` / `*.e2e.spec.ts` | e2e |
74
+ *
75
+ * It supports both running all tests and targeting specific projects via the
76
+ * `--project` command line argument.
77
+ *
78
+ * Results are cached in static properties so that repeated config evaluations
79
+ * during watch mode or HMR do not re-scan the filesystem.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * import { VitestConfig } from "@savvy-web/vitest";
84
+ *
85
+ * export default VitestConfig.create(
86
+ * ({ projects, coverage, reporters }) => ({
87
+ * test: {
88
+ * reporters,
89
+ * projects: projects.map((p) => p.toConfig()),
90
+ * coverage: { provider: "v8", ...coverage },
91
+ * },
92
+ * }),
93
+ * );
94
+ * ```
95
+ *
96
+ * @public
97
+ */
98
+ export declare class VitestConfig {
99
+ /** Default coverage threshold percentage applied when not overridden. */
100
+ static readonly DEFAULT_THRESHOLD = 80;
101
+ private static cachedProjects;
102
+ private static cachedVitestProjects;
103
+ /**
104
+ * Creates a complete Vitest configuration by discovering workspace projects
105
+ * and generating appropriate settings.
106
+ *
107
+ * @param callback - Receives discovered projects, coverage config,
108
+ * reporters, and CI flag; returns a Vitest config
109
+ * @param options - Optional configuration including coverage thresholds
110
+ * @returns The Vitest configuration returned by the callback
111
+ *
112
+ * @see {@link VitestConfigCallback} for the callback signature
113
+ * @see {@link VitestConfigCreateOptions} for available options
114
+ */
115
+ static create(callback: VitestConfigCallback, options?: VitestConfigCreateOptions): Promise<ViteUserConfig> | ViteUserConfig;
116
+ /**
117
+ * Extracts the specific project name from command line arguments.
118
+ *
119
+ * @privateRemarks
120
+ * Supports both `--project=value` and `--project value` formats to match
121
+ * Vitest's own argument parsing behavior. Only the first `--project` flag
122
+ * is returned; repeated flags (e.g. `--project foo --project bar`) are
123
+ * ignored since multi-project coverage scoping is not supported.
124
+ */
125
+ private static getSpecificProject;
126
+ /**
127
+ * Reads the `name` field from a package's `package.json`.
128
+ *
129
+ * @privateRemarks
130
+ * Uses try/catch because the package.json may not exist or may be malformed.
131
+ * Returns `null` to signal the caller should skip this package.
132
+ */
133
+ private static getPackageNameFromPath;
134
+ /**
135
+ * Checks whether a path is an existing directory.
136
+ *
137
+ * @privateRemarks
138
+ * Consolidates the repeated `statSync` + `isDirectory()` + try/catch
139
+ * pattern used throughout workspace discovery.
140
+ */
141
+ private static isDirectory;
142
+ /**
143
+ * Recursively scans a directory for test files and classifies them by kind.
144
+ *
145
+ * @privateRemarks
146
+ * Short-circuits as soon as both unit and e2e files are found, avoiding
147
+ * unnecessary filesystem traversal.
148
+ */
149
+ private static scanForTestFiles;
150
+ /**
151
+ * Builds include glob patterns for a given relative path and optional
152
+ * test directory.
153
+ */
154
+ private static buildIncludes;
155
+ /**
156
+ * Discovers all packages in the workspace that contain a `src/` directory
157
+ * and generates {@link VitestProject} instances based on filename conventions.
158
+ *
159
+ * @privateRemarks
160
+ * When a package has both unit and e2e test files, projects are suffixed
161
+ * with `:unit` and `:e2e` to disambiguate. Packages with `src/` but no
162
+ * test files still get a unit project entry as a forward-looking placeholder.
163
+ */
164
+ private static discoverWorkspaceProjects;
165
+ /**
166
+ * Generates coverage configuration including thresholds.
167
+ *
168
+ * @privateRemarks
169
+ * Strips `:unit`/`:e2e` suffix when looking up project paths for
170
+ * `--project` filtering, since coverage applies to the entire package
171
+ * regardless of test kind.
172
+ */
173
+ private static getCoverageConfig;
174
+ }
175
+
176
+ /**
177
+ * Callback that receives discovered configuration and returns a Vitest config.
178
+ *
179
+ * @param config - Object containing discovered projects, coverage settings,
180
+ * reporters array, and CI detection flag
181
+ * @returns A Vitest user configuration, optionally async
182
+ *
183
+ * @see {@link VitestConfig.create} for the entry point that invokes this callback
184
+ *
185
+ * @public
186
+ */
187
+ export declare type VitestConfigCallback = (config: {
188
+ /** Discovered {@link VitestProject} instances for the workspace. */
189
+ projects: VitestProject[];
190
+ /** Generated coverage configuration with thresholds. */
191
+ coverage: CoverageConfig;
192
+ /** Reporter names based on environment (adds `"github-actions"` in CI). */
193
+ reporters: string[];
194
+ /** Whether the current environment is GitHub Actions CI. */
195
+ isCI: boolean;
196
+ }) => ViteUserConfig | Promise<ViteUserConfig>;
197
+
198
+ /**
199
+ * Options for {@link VitestConfig.create}.
200
+ *
201
+ * @public
202
+ */
203
+ export declare interface VitestConfigCreateOptions {
204
+ /**
205
+ * Coverage thresholds applied per-file.
206
+ *
207
+ * @remarks
208
+ * Any omitted metric defaults to {@link VitestConfig.DEFAULT_THRESHOLD | 80}.
209
+ *
210
+ * @defaultValue `{ lines: 80, functions: 80, branches: 80, statements: 80 }`
211
+ */
212
+ thresholds?: {
213
+ /** Minimum line coverage percentage. */
214
+ lines?: number;
215
+ /** Minimum function coverage percentage. */
216
+ functions?: number;
217
+ /** Minimum branch coverage percentage. */
218
+ branches?: number;
219
+ /** Minimum statement coverage percentage. */
220
+ statements?: number;
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Represents a single Vitest project with sensible defaults per test kind.
226
+ *
227
+ * @remarks
228
+ * Instances are created through static factory methods. The private constructor
229
+ * enforces that all projects are built with validated merge semantics.
230
+ *
231
+ * Override merge precedence (highest wins):
232
+ * 1. `name` and `include` from options (always win)
233
+ * 2. `overrides.test` fields
234
+ * 3. Factory defaults for `test`
235
+ * 4. Top-level: `overrides` rest spreads over factory defaults rest
236
+ *
237
+ * @example
238
+ * ```typescript
239
+ * import { VitestProject } from "@savvy-web/vitest";
240
+ *
241
+ * const unitProject = VitestProject.unit({
242
+ * name: "@savvy-web/my-lib",
243
+ * include: ["src/**\/*.test.ts"],
244
+ * });
245
+ *
246
+ * const e2eProject = VitestProject.e2e({
247
+ * name: "@savvy-web/my-lib:e2e",
248
+ * include: ["test/e2e/**\/*.test.ts"],
249
+ * });
250
+ *
251
+ * const config = unitProject.toConfig();
252
+ * ```
253
+ *
254
+ * @public
255
+ */
256
+ export declare class VitestProject {
257
+ #private;
258
+ private constructor();
259
+ /**
260
+ * The project name.
261
+ * @see {@link VitestProjectOptions.name}
262
+ */
263
+ get name(): string;
264
+ /**
265
+ * The test kind (e.g., `"unit"`, `"e2e"`, or a custom string).
266
+ * @see {@link VitestProjectKind}
267
+ */
268
+ get kind(): VitestProjectKind;
269
+ /**
270
+ * Returns the vitest-native inline configuration object.
271
+ *
272
+ * @returns A {@link https://vitest.dev/config/ | TestProjectInlineConfiguration}
273
+ * with all defaults and overrides merged
274
+ */
275
+ toConfig(): TestProjectInlineConfiguration;
276
+ /**
277
+ * Creates a unit test project with sensible defaults.
278
+ *
279
+ * @remarks
280
+ * Defaults applied: `extends: true`, `environment: "node"`.
281
+ *
282
+ * @param options - Project options (the `kind` field is forced to `"unit"`)
283
+ * @returns A new {@link VitestProject} configured for unit tests
284
+ *
285
+ * @example
286
+ * ```typescript
287
+ * import { VitestProject } from "@savvy-web/vitest";
288
+ *
289
+ * const project = VitestProject.unit({
290
+ * name: "@savvy-web/my-lib",
291
+ * include: ["src/**\/*.test.ts"],
292
+ * });
293
+ * ```
294
+ */
295
+ static unit(options: VitestProjectOptions): VitestProject;
296
+ /**
297
+ * Creates an e2e test project with sensible defaults.
298
+ *
299
+ * @remarks
300
+ * Defaults applied: `extends: true`, `environment: "node"`,
301
+ * `testTimeout: 120_000`, `hookTimeout: 60_000`,
302
+ * `maxConcurrency: clamp(floor(cpus / 2), 1, 8)`.
303
+ *
304
+ * @param options - Project options (the `kind` field is forced to `"e2e"`)
305
+ * @returns A new {@link VitestProject} configured for e2e tests
306
+ *
307
+ * @example
308
+ * ```typescript
309
+ * import { VitestProject } from "@savvy-web/vitest";
310
+ *
311
+ * const project = VitestProject.e2e({
312
+ * name: "@savvy-web/my-lib:e2e",
313
+ * include: ["test/e2e/**\/*.test.ts"],
314
+ * });
315
+ * ```
316
+ */
317
+ static e2e(options: VitestProjectOptions): VitestProject;
318
+ /**
319
+ * Creates a custom test project with no preset defaults beyond `extends: true`.
320
+ *
321
+ * @remarks
322
+ * Use this factory when the built-in `unit()` and `e2e()` presets do not
323
+ * match your needs. The `kind` string is stored on the instance but does
324
+ * not influence any default configuration.
325
+ *
326
+ * @param kind - A custom kind string (e.g., `"integration"`, `"smoke"`)
327
+ * @param options - Project options
328
+ * @returns A new {@link VitestProject} with no preset defaults
329
+ *
330
+ * @example
331
+ * ```typescript
332
+ * import { VitestProject } from "@savvy-web/vitest";
333
+ *
334
+ * const project = VitestProject.custom("integration", {
335
+ * name: "@savvy-web/my-lib:integration",
336
+ * include: ["test/integration/**\/*.test.ts"],
337
+ * });
338
+ * ```
339
+ */
340
+ static custom(kind: VitestProjectKind, options: VitestProjectOptions): VitestProject;
341
+ }
342
+
343
+ /**
344
+ * The kind of test a {@link VitestProject} represents.
345
+ *
346
+ * @remarks
347
+ * The built-in factories {@link VitestProject.unit | unit()} and
348
+ * {@link VitestProject.e2e | e2e()} correspond to the `"unit"` and `"e2e"`
349
+ * values respectively. The {@link VitestProject.custom | custom()} factory
350
+ * accepts an arbitrary string that is stored as the kind.
351
+ *
352
+ * @public
353
+ */
354
+ export declare type VitestProjectKind = "unit" | "e2e" | (string & {});
355
+
356
+ /**
357
+ * Options for constructing a {@link VitestProject}.
358
+ *
359
+ * @see {@link VitestProject.unit} for creating unit test projects
360
+ * @see {@link VitestProject.e2e} for creating e2e test projects
361
+ * @see {@link VitestProject.custom} for creating custom test projects
362
+ *
363
+ * @public
364
+ */
365
+ export declare interface VitestProjectOptions {
366
+ /**
367
+ * The project name, typically a package name optionally suffixed
368
+ * with `:unit` or `:e2e` when both kinds exist in the same package.
369
+ */
370
+ name: string;
371
+ /** Glob patterns for test file inclusion. */
372
+ include: string[];
373
+ /**
374
+ * The test kind. Defaults to `"unit"`.
375
+ * @defaultValue `"unit"`
376
+ */
377
+ kind?: VitestProjectKind;
378
+ /**
379
+ * Vitest-native config fields to merge over the factory defaults.
380
+ *
381
+ * @remarks
382
+ * The {@link VitestProjectOptions.name | name} and
383
+ * {@link VitestProjectOptions.include | include} fields always take
384
+ * precedence over any values provided in overrides.
385
+ *
386
+ * @see {@link https://vitest.dev/config/ | Vitest Configuration} for available fields
387
+ */
388
+ overrides?: Partial<TestProjectInlineConfiguration>;
389
+ }
390
+
391
+ export { }
package/index.js ADDED
@@ -0,0 +1,232 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { cpus } from "node:os";
3
+ import { join, relative } from "node:path";
4
+ import { getWorkspaceManagerRoot, getWorkspacePackagePaths } from "workspace-tools";
5
+ class VitestProject {
6
+ #name;
7
+ #kind;
8
+ #config;
9
+ constructor(options, defaults){
10
+ this.#name = options.name;
11
+ this.#kind = options.kind ?? "unit";
12
+ const { test: defaultTest, ...defaultRest } = defaults;
13
+ const { test: overrideTest, ...overrideRest } = options.overrides ?? {};
14
+ this.#config = {
15
+ extends: true,
16
+ ...defaultRest,
17
+ ...overrideRest,
18
+ test: {
19
+ ...defaultTest,
20
+ ...overrideTest,
21
+ name: options.name,
22
+ include: options.include
23
+ }
24
+ };
25
+ }
26
+ get name() {
27
+ return this.#name;
28
+ }
29
+ get kind() {
30
+ return this.#kind;
31
+ }
32
+ toConfig() {
33
+ return this.#config;
34
+ }
35
+ static unit(options) {
36
+ return new VitestProject({
37
+ ...options,
38
+ kind: "unit"
39
+ }, {
40
+ test: {
41
+ environment: "node"
42
+ }
43
+ });
44
+ }
45
+ static e2e(options) {
46
+ const concurrency = Math.max(1, Math.min(8, Math.floor(cpus().length / 2)));
47
+ return new VitestProject({
48
+ ...options,
49
+ kind: "e2e"
50
+ }, {
51
+ test: {
52
+ environment: "node",
53
+ testTimeout: 120000,
54
+ hookTimeout: 60000,
55
+ maxConcurrency: concurrency
56
+ }
57
+ });
58
+ }
59
+ static custom(kind, options) {
60
+ return new VitestProject({
61
+ ...options,
62
+ kind
63
+ }, {});
64
+ }
65
+ }
66
+ class VitestConfig {
67
+ static DEFAULT_THRESHOLD = 80;
68
+ static cachedProjects = null;
69
+ static cachedVitestProjects = null;
70
+ static create(callback, options) {
71
+ const specificProject = VitestConfig.getSpecificProject();
72
+ const { projects, vitestProjects } = VitestConfig.discoverWorkspaceProjects();
73
+ const coverage = VitestConfig.getCoverageConfig(specificProject, projects, options);
74
+ const isCI = Boolean(process.env.GITHUB_ACTIONS);
75
+ const reporters = isCI ? [
76
+ "default",
77
+ "github-actions"
78
+ ] : [
79
+ "default"
80
+ ];
81
+ return callback({
82
+ projects: vitestProjects,
83
+ coverage,
84
+ reporters,
85
+ isCI
86
+ });
87
+ }
88
+ static getSpecificProject() {
89
+ const args = process.argv;
90
+ const projectArg = args.find((arg)=>arg.startsWith("--project="));
91
+ if (projectArg) return projectArg.split("=")[1] ?? null;
92
+ const projectIndex = args.indexOf("--project");
93
+ if (-1 !== projectIndex && projectIndex + 1 < args.length) return args[projectIndex + 1] ?? null;
94
+ return null;
95
+ }
96
+ static getPackageNameFromPath(packagePath) {
97
+ try {
98
+ const content = readFileSync(join(packagePath, "package.json"), "utf8");
99
+ return JSON.parse(content).name ?? null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ static isDirectory(dirPath) {
105
+ try {
106
+ return statSync(dirPath).isDirectory();
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+ static scanForTestFiles(dirPath) {
112
+ let hasUnit = false;
113
+ let hasE2e = false;
114
+ try {
115
+ const entries = readdirSync(dirPath, {
116
+ withFileTypes: true
117
+ });
118
+ for (const entry of entries){
119
+ if (entry.isDirectory()) {
120
+ const sub = VitestConfig.scanForTestFiles(join(dirPath, entry.name));
121
+ hasUnit = hasUnit || sub.hasUnit;
122
+ hasE2e = hasE2e || sub.hasE2e;
123
+ } else if (entry.isFile()) {
124
+ if (/\.e2e\.(test|spec)\.ts$/.test(entry.name)) hasE2e = true;
125
+ else if (/\.(test|spec)\.ts$/.test(entry.name)) hasUnit = true;
126
+ }
127
+ if (hasUnit && hasE2e) break;
128
+ }
129
+ } catch {}
130
+ return {
131
+ hasUnit,
132
+ hasE2e
133
+ };
134
+ }
135
+ static buildIncludes(srcGlob, testGlob, pattern) {
136
+ const includes = [
137
+ `${srcGlob}/**/${pattern}`
138
+ ];
139
+ if (testGlob) includes.push(`${testGlob}/**/${pattern}`);
140
+ return includes;
141
+ }
142
+ static discoverWorkspaceProjects() {
143
+ if (VitestConfig.cachedProjects && VitestConfig.cachedVitestProjects) return {
144
+ projects: VitestConfig.cachedProjects,
145
+ vitestProjects: VitestConfig.cachedVitestProjects
146
+ };
147
+ const cwd = process.cwd();
148
+ const workspaceRoot = getWorkspaceManagerRoot(cwd) ?? cwd;
149
+ const workspacePaths = getWorkspacePackagePaths(workspaceRoot) ?? [];
150
+ const projects = {};
151
+ const vitestProjects = [];
152
+ for (const pkgPath of workspacePaths){
153
+ const packageName = VitestConfig.getPackageNameFromPath(pkgPath);
154
+ if (!packageName) continue;
155
+ const srcDirPath = join(pkgPath, "src");
156
+ if (!VitestConfig.isDirectory(srcDirPath)) continue;
157
+ const relativePath = relative(workspaceRoot, pkgPath) || ".";
158
+ projects[packageName] = relativePath;
159
+ const testDirPath = join(pkgPath, "__test__");
160
+ const hasTestDir = VitestConfig.isDirectory(testDirPath);
161
+ const srcScan = VitestConfig.scanForTestFiles(srcDirPath);
162
+ const testScan = hasTestDir ? VitestConfig.scanForTestFiles(testDirPath) : {
163
+ hasUnit: false,
164
+ hasE2e: false
165
+ };
166
+ const hasUnit = srcScan.hasUnit || testScan.hasUnit;
167
+ const hasE2e = srcScan.hasE2e || testScan.hasE2e;
168
+ const hasBoth = hasUnit && hasE2e;
169
+ const prefix = "." === relativePath ? "" : `${relativePath}/`;
170
+ const srcGlob = `${prefix}src`;
171
+ const testGlob = hasTestDir ? `${prefix}__test__` : null;
172
+ if (hasUnit) vitestProjects.push(VitestProject.unit({
173
+ name: hasBoth ? `${packageName}:unit` : packageName,
174
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.ts"),
175
+ overrides: {
176
+ test: {
177
+ exclude: [
178
+ "**/*.e2e.{test,spec}.ts"
179
+ ]
180
+ }
181
+ }
182
+ }));
183
+ if (hasE2e) vitestProjects.push(VitestProject.e2e({
184
+ name: hasBoth ? `${packageName}:e2e` : packageName,
185
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.e2e.{test,spec}.ts")
186
+ }));
187
+ if (!hasUnit && !hasE2e) vitestProjects.push(VitestProject.unit({
188
+ name: packageName,
189
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.ts")
190
+ }));
191
+ }
192
+ VitestConfig.cachedProjects = projects;
193
+ VitestConfig.cachedVitestProjects = vitestProjects;
194
+ return {
195
+ projects,
196
+ vitestProjects
197
+ };
198
+ }
199
+ static getCoverageConfig(specificProject, projects, options) {
200
+ const exclude = [
201
+ "**/*.{test,spec}.ts"
202
+ ];
203
+ const t = VitestConfig.DEFAULT_THRESHOLD;
204
+ const thresholds = {
205
+ lines: options?.thresholds?.lines ?? t,
206
+ functions: options?.thresholds?.functions ?? t,
207
+ branches: options?.thresholds?.branches ?? t,
208
+ statements: options?.thresholds?.statements ?? t
209
+ };
210
+ const toSrcGlob = (relPath)=>{
211
+ const prefix = "." === relPath ? "" : `${relPath}/`;
212
+ return `${prefix}src/**/*.ts`;
213
+ };
214
+ if (specificProject) {
215
+ const baseName = specificProject.replace(/:(unit|e2e)$/, "");
216
+ const relPath = projects[baseName];
217
+ if (void 0 !== relPath) return {
218
+ include: [
219
+ toSrcGlob(relPath)
220
+ ],
221
+ exclude,
222
+ thresholds
223
+ };
224
+ }
225
+ return {
226
+ include: Object.values(projects).map(toSrcGlob),
227
+ exclude,
228
+ thresholds
229
+ };
230
+ }
231
+ }
232
+ export { VitestConfig, VitestProject };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@savvy-web/vitest",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Vitest utility functions for Silk Suite deployment system",
6
+ "homepage": "https://github.com/savvy-web/vitest#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/savvy-web/vitest.git"
10
+ },
11
+ "license": "MIT",
12
+ "author": {
13
+ "name": "C. Spencer Beggs",
14
+ "email": "spencer@savvyweb.systems",
15
+ "url": "https://savvyweb.systems"
16
+ },
17
+ "type": "module",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./index.d.ts",
21
+ "import": "./index.js"
22
+ }
23
+ },
24
+ "dependencies": {
25
+ "workspace-tools": "^0.41.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@types/node": "^25.2.3",
29
+ "@typescript/native-preview": "^7.0.0-dev.20260216.1",
30
+ "@vitest/coverage-v8": "^4.0.18",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^4.0.18"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@types/node": {
36
+ "optional": false
37
+ },
38
+ "@typescript/native-preview": {
39
+ "optional": false
40
+ },
41
+ "@vitest/coverage-v8": {
42
+ "optional": false
43
+ },
44
+ "typescript": {
45
+ "optional": false
46
+ },
47
+ "vitest": {
48
+ "optional": false
49
+ }
50
+ },
51
+ "files": [
52
+ "!tsconfig.json",
53
+ "!tsdoc.json",
54
+ "!vitest.api.json",
55
+ "LICENSE",
56
+ "README.md",
57
+ "index.d.ts",
58
+ "index.js",
59
+ "package.json",
60
+ "tsdoc-metadata.json"
61
+ ]
62
+ }
@@ -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.56.3"
9
+ }
10
+ ]
11
+ }