@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 +21 -0
- package/README.md +151 -0
- package/index.d.ts +391 -0
- package/index.js +232 -0
- package/package.json +62 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
}
|