@savvy-web/vitest 0.2.2 → 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.
Files changed (4) hide show
  1. package/README.md +67 -117
  2. package/index.d.ts +236 -93
  3. package/index.js +316 -45
  4. package/package.json +8 -4
package/README.md CHANGED
@@ -1,9 +1,21 @@
1
1
  # @savvy-web/vitest
2
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.
3
+ [![npm version](https://img.shields.io/npm/v/@savvy-web/vitest)](https://www.npmjs.com/package/@savvy-web/vitest)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Automatic Vitest project configuration discovery for pnpm monorepo
7
+ workspaces. Scans workspace packages, classifies test files as unit,
8
+ e2e, or integration by filename convention, and generates multi-project
9
+ Vitest configs with coverage thresholds, `vitest-agent-reporter`
10
+ integration, and CI-aware reporters.
11
+
12
+ ## Features
13
+
14
+ - Zero-config workspace discovery with automatic test classification
15
+ - Named coverage levels (`none`, `basic`, `standard`, `strict`, `full`)
16
+ - Per-kind and per-project override support with chainable mutation API
17
+ - Built-in `vitest-agent-reporter` integration for AI-assisted workflows
18
+ - CI-aware reporters with automatic `github-actions` reporter in CI
7
19
 
8
20
  ## Installation
9
21
 
@@ -11,140 +23,78 @@ automatically.
11
23
  pnpm add @savvy-web/vitest
12
24
  ```
13
25
 
14
- Peer dependencies: `vitest`, `@vitest/coverage-v8`
26
+ Peer dependencies: `vitest` >=4.1.0, `@vitest/coverage-v8` >=4.1.0, `vitest-agent-reporter` >=0.2.0
15
27
 
16
28
  ## Quick Start
17
29
 
18
- Create a single `vitest.config.ts` at your workspace root:
19
-
20
30
  ```typescript
21
31
  import { VitestConfig } from "@savvy-web/vitest";
22
32
 
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
- );
33
+ // Zero config -- everything automatic
34
+ export default VitestConfig.create();
32
35
  ```
33
36
 
34
- Every workspace package containing a `src/` directory is discovered and
35
- configured. No per-package Vitest config files needed.
36
-
37
- ## Manual Projects
37
+ ## Directory Structure
38
+
39
+ ```text
40
+ project-root/ # or monorepo leaf workspace
41
+ lib/ # Configs, scripts -- linted, typechecked, no tests
42
+ src/ # Module source -- may contain co-located tests
43
+ __test__/ # Dedicated test directory
44
+ utils/ # Shared test helpers (excluded from discovery)
45
+ fixtures/ # Test fixtures (excluded from lint/typecheck/discovery)
46
+ *.test.ts # Unit tests (no signifier)
47
+ *.unit.test.ts # Unit tests (explicit signifier)
48
+ unit/ # Optional unit subdirectory
49
+ utils/ # Excluded
50
+ fixtures/ # Excluded
51
+ e2e/
52
+ utils/ # Excluded
53
+ fixtures/ # Excluded
54
+ *.e2e.test.ts
55
+ integration/
56
+ utils/ # Excluded
57
+ fixtures/ # Excluded
58
+ *.int.test.ts
59
+ vitest.setup.ts # Optional -- auto-detected, added to all projects
60
+ ```
38
61
 
39
- When you need explicit control, use the factory methods directly:
62
+ ## Examples
40
63
 
41
64
  ```typescript
42
- import { VitestProject } from "@savvy-web/vitest";
65
+ // Named coverage level
66
+ export default VitestConfig.create({ coverage: "standard" });
43
67
 
44
- const unit = VitestProject.unit({
45
- name: "@savvy-web/my-lib",
46
- include: ["src/**/*.test.ts"],
68
+ // Per-kind overrides (object form)
69
+ export default VitestConfig.create({
70
+ unit: { environment: "jsdom" },
47
71
  });
48
72
 
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 },
73
+ // Per-project overrides (callback form)
74
+ export default VitestConfig.create({
75
+ e2e: (projects) => {
76
+ projects.get("@savvy-web/auth:e2e")
77
+ ?.override({ test: { testTimeout: 300_000 } })
78
+ .addCoverageExclude("src/generated/**");
54
79
  },
55
80
  });
56
- ```
57
-
58
- ## API Reference
59
81
 
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
- }
82
+ // Escape hatch for Vite-level config
83
+ export default VitestConfig.create(
84
+ { coverage: "standard" },
85
+ (config) => {
86
+ config.resolve = { alias: { "@": "/src" } };
87
+ },
88
+ );
120
89
  ```
121
90
 
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.
91
+ ## Documentation
142
92
 
143
- ## CI Integration
93
+ For configuration, API reference, and advanced usage, see [docs/](./docs/).
144
94
 
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.
95
+ - [API Reference](./docs/api.md) -- Complete reference for all exports
96
+ - [Test Discovery](./docs/discovery.md) -- Workspace scanning, test classification, and coverage scoping
97
+ - [Usage Guides](./docs/guides.md) -- Recipes for kind overrides, per-project mutation, coverage, agent reporter, and escape hatches
148
98
 
149
99
  ## License
150
100
 
package/index.d.ts CHANGED
@@ -15,48 +15,70 @@
15
15
  * ```typescript
16
16
  * import { VitestConfig } from "@savvy-web/vitest";
17
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
- * );
18
+ * export default VitestConfig.create();
27
19
  * ```
28
20
  *
29
21
  * @packageDocumentation
30
22
  */
31
23
 
24
+ import type { AgentPluginOptions } from 'vitest-agent-reporter';
32
25
  import { TestProjectInlineConfiguration } from 'vitest/config';
33
26
  import type { ViteUserConfig } from 'vitest/config';
34
27
 
35
28
  /**
36
- * Coverage configuration passed to the {@link VitestConfigCallback}.
29
+ * Pass-through configuration for `vitest-agent-reporter`'s `AgentPlugin`.
37
30
  *
38
- * @see {@link VitestConfig.create} for how this is generated
31
+ * @see {@link VitestConfigOptions.agentReporter}
39
32
  *
40
33
  * @public
41
34
  */
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
- };
35
+ export declare type AgentReporterConfig = AgentPluginOptions;
36
+
37
+ /**
38
+ * Named coverage level presets available on {@link VitestConfig.COVERAGE_LEVELS}.
39
+ *
40
+ * @public
41
+ */
42
+ export declare type CoverageLevelName = "none" | "basic" | "standard" | "strict" | "full";
43
+
44
+ /**
45
+ * Coverage thresholds with all four metrics required.
46
+ *
47
+ * @public
48
+ */
49
+ export declare interface CoverageThresholds {
50
+ /** Minimum line coverage percentage. */
51
+ lines: number;
52
+ /** Minimum function coverage percentage. */
53
+ functions: number;
54
+ /** Minimum branch coverage percentage. */
55
+ branches: number;
56
+ /** Minimum statement coverage percentage. */
57
+ statements: number;
58
58
  }
59
59
 
60
+ /**
61
+ * Override for a specific test kind (unit, e2e, int).
62
+ *
63
+ * @remarks
64
+ * When an object is provided, it is merged into every project of that kind.
65
+ * When a callback is provided, it receives a Map of project name to
66
+ * {@link VitestProject} for fine-grained per-project mutation.
67
+ *
68
+ * @public
69
+ */
70
+ export declare type KindOverride = Partial<TestProjectInlineConfiguration["test"]> | ((projects: Map<string, VitestProject>) => void);
71
+
72
+ /**
73
+ * Post-process callback for escape-hatch customization of the assembled config.
74
+ *
75
+ * @param config - The assembled Vitest configuration
76
+ * @returns A replacement config, or void to use the mutated original
77
+ *
78
+ * @public
79
+ */
80
+ export declare type PostProcessCallback = (config: ViteUserConfig) => ViteUserConfig | undefined;
81
+
60
82
  export { TestProjectInlineConfiguration }
61
83
 
62
84
  /**
@@ -82,47 +104,62 @@ export { TestProjectInlineConfiguration }
82
104
  * ```typescript
83
105
  * import { VitestConfig } from "@savvy-web/vitest";
84
106
  *
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
- * );
107
+ * export default VitestConfig.create();
94
108
  * ```
95
109
  *
96
110
  * @public
97
111
  */
98
112
  export declare class VitestConfig {
99
- /** Default coverage threshold percentage applied when not overridden. */
100
- static readonly DEFAULT_THRESHOLD = 80;
113
+ /** Default glob patterns excluded from coverage reporting. */
114
+ private static readonly DEFAULT_COVERAGE_EXCLUDE;
115
+ /**
116
+ * Named coverage level presets.
117
+ *
118
+ * @remarks
119
+ * Use a level name with the `coverage` option in {@link VitestConfig.create}
120
+ * to apply a preset. The object is frozen and cannot be mutated.
121
+ *
122
+ * | Level | lines | branches | functions | statements |
123
+ * | -------- | ----- | -------- | --------- | ---------- |
124
+ * | none | 0 | 0 | 0 | 0 |
125
+ * | basic | 50 | 50 | 50 | 50 |
126
+ * | standard | 70 | 65 | 70 | 70 |
127
+ * | strict | 80 | 75 | 80 | 80 |
128
+ * | full | 90 | 85 | 90 | 90 |
129
+ */
130
+ static readonly COVERAGE_LEVELS: Readonly<Record<CoverageLevelName, CoverageThresholds>>;
101
131
  private static cachedProjects;
102
132
  private static cachedVitestProjects;
103
133
  /**
104
134
  * Creates a complete Vitest configuration by discovering workspace projects
105
135
  * and generating appropriate settings.
106
136
  *
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
137
+ * @param options - Optional declarative configuration
138
+ * @param postProcess - Optional escape-hatch callback for full config control
139
+ * @returns The assembled Vitest configuration
111
140
  *
112
- * @see {@link VitestConfigCallback} for the callback signature
113
- * @see {@link VitestConfigCreateOptions} for available options
141
+ * @see {@link VitestConfigOptions} for available options
142
+ * @see {@link PostProcessCallback} for the post-process callback signature
143
+ */
144
+ static create(options?: VitestConfigOptions, postProcess?: PostProcessCallback): Promise<ViteUserConfig>;
145
+ /**
146
+ * Applies kind-specific overrides to discovered projects.
147
+ *
148
+ * @privateRemarks
149
+ * When the override is an object, it is merged into every project of the
150
+ * matching kind. When it is a callback, it receives a Map of project name
151
+ * to {@link VitestProject} for fine-grained per-project mutation.
114
152
  */
115
- static create(callback: VitestConfigCallback, options?: VitestConfigCreateOptions): Promise<ViteUserConfig> | ViteUserConfig;
153
+ private static applyKindOverrides;
116
154
  /**
117
- * Extracts the specific project name from command line arguments.
155
+ * Extracts all specific project names from command line arguments.
118
156
  *
119
157
  * @privateRemarks
120
158
  * 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.
159
+ * Vitest's own argument parsing behavior. All `--project` flags are
160
+ * collected to support multi-project coverage scoping.
124
161
  */
125
- private static getSpecificProject;
162
+ private static getSpecificProjects;
126
163
  /**
127
164
  * Reads the `name` field from a package's `package.json`.
128
165
  *
@@ -139,12 +176,22 @@ export declare class VitestConfig {
139
176
  * pattern used throughout workspace discovery.
140
177
  */
141
178
  private static isDirectory;
179
+ /** Extensions probed (in order) when detecting a setup file. */
180
+ private static readonly SETUP_FILE_EXTENSIONS;
181
+ /**
182
+ * Detects a `vitest.setup.{ts,tsx,js,jsx}` file at the package root.
183
+ *
184
+ * @privateRemarks
185
+ * First match wins. Returns just the filename (e.g. `"vitest.setup.ts"`)
186
+ * so the caller can prepend the relative prefix as needed.
187
+ */
188
+ private static detectSetupFile;
142
189
  /**
143
190
  * Recursively scans a directory for test files and classifies them by kind.
144
191
  *
145
192
  * @privateRemarks
146
- * Short-circuits as soon as both unit and e2e files are found, avoiding
147
- * unnecessary filesystem traversal.
193
+ * Short-circuits as soon as all three kinds (unit, e2e, and int) are
194
+ * found, avoiding unnecessary filesystem traversal.
148
195
  */
149
196
  private static scanForTestFiles;
150
197
  /**
@@ -152,6 +199,19 @@ export declare class VitestConfig {
152
199
  * test directory.
153
200
  */
154
201
  private static buildIncludes;
202
+ /**
203
+ * Conventional subdirectories under `__test__/` that hold helpers, not
204
+ * test files, and should be excluded from test discovery.
205
+ */
206
+ private static readonly TEST_DIR_EXCLUSIONS;
207
+ /**
208
+ * Returns exclusion patterns for fixture/utils directories under
209
+ * `__test__/`, scoped to the given package prefix.
210
+ *
211
+ * @param prefix - Either `"<relativePath>/"` for non-root packages or
212
+ * `""` for the workspace root.
213
+ */
214
+ private static buildTestDirExclusions;
155
215
  /**
156
216
  * Discovers all packages in the workspace that contain a `src/` directory
157
217
  * and generates {@link VitestProject} instances based on filename conventions.
@@ -162,63 +222,68 @@ export declare class VitestConfig {
162
222
  * test files still get a unit project entry as a forward-looking placeholder.
163
223
  */
164
224
  private static discoverWorkspaceProjects;
225
+ /**
226
+ * Resolves coverage thresholds from options.
227
+ *
228
+ * @privateRemarks
229
+ * Priority: `options.coverage` (name or object) \> `COVERAGE_LEVELS.strict`.
230
+ */
231
+ private static resolveThresholds;
165
232
  /**
166
233
  * Generates coverage configuration including thresholds.
167
234
  *
168
235
  * @privateRemarks
169
- * Strips `:unit`/`:e2e` suffix when looking up project paths for
236
+ * Strips `:unit`/`:e2e`/`:int` suffix when looking up project paths for
170
237
  * `--project` filtering, since coverage applies to the entire package
171
- * regardless of test kind.
238
+ * regardless of test kind. When multiple `--project` flags are provided,
239
+ * coverage includes are unioned across all matched packages.
172
240
  */
173
241
  private static getCoverageConfig;
174
242
  }
175
243
 
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
244
  /**
199
245
  * Options for {@link VitestConfig.create}.
200
246
  *
201
247
  * @public
202
248
  */
203
- export declare interface VitestConfigCreateOptions {
249
+ export declare interface VitestConfigOptions {
204
250
  /**
205
- * Coverage thresholds applied per-file.
251
+ * Coverage level name or explicit thresholds object.
206
252
  *
207
253
  * @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
- };
254
+ * When a {@link CoverageLevelName} string is provided, the corresponding
255
+ * preset from {@link VitestConfig.COVERAGE_LEVELS} is used. When a
256
+ * {@link CoverageThresholds} object is provided, it is used directly.
257
+ *
258
+ * @defaultValue `"strict"` (lines: 80, branches: 75, functions: 80, statements: 80)
259
+ */
260
+ coverage?: CoverageLevelName | CoverageThresholds;
261
+ /** Additional glob patterns to exclude from coverage reporting. */
262
+ coverageExclude?: string[];
263
+ /**
264
+ * Whether to inject the vitest-agent-reporter plugin.
265
+ *
266
+ * @remarks
267
+ * When `true` or an {@link AgentReporterConfig} object, the plugin is
268
+ * injected with the given options (with `strategy` defaulting to `"own"`
269
+ * and `coverageThresholds` populated from the resolved coverage level).
270
+ * When `false`, the plugin is not injected.
271
+ *
272
+ * @defaultValue `true`
273
+ */
274
+ agentReporter?: boolean | AgentReporterConfig;
275
+ /**
276
+ * Vitest pool mode.
277
+ *
278
+ * @defaultValue Uses Vitest's default (threads)
279
+ */
280
+ pool?: "threads" | "forks" | "vmThreads" | "vmForks";
281
+ /** Override configuration for all unit test projects. */
282
+ unit?: KindOverride;
283
+ /** Override configuration for all e2e test projects. */
284
+ e2e?: KindOverride;
285
+ /** Override configuration for all integration test projects. */
286
+ int?: KindOverride;
222
287
  }
223
288
 
224
289
  /**
@@ -266,6 +331,14 @@ export declare class VitestProject {
266
331
  * @see {@link VitestProjectKind}
267
332
  */
268
333
  get kind(): VitestProjectKind;
334
+ /**
335
+ * Coverage exclusion patterns accumulated via {@link addCoverageExclude}.
336
+ *
337
+ * @remarks
338
+ * These patterns are not embedded in the inline project config but are
339
+ * made available for the workspace-level coverage configuration to consume.
340
+ */
341
+ get coverageExcludes(): readonly string[];
269
342
  /**
270
343
  * Returns the vitest-native inline configuration object.
271
344
  *
@@ -273,6 +346,54 @@ export declare class VitestProject {
273
346
  * with all defaults and overrides merged
274
347
  */
275
348
  toConfig(): TestProjectInlineConfiguration;
349
+ /**
350
+ * Creates a clone of this project with independent config state.
351
+ *
352
+ * @remarks
353
+ * The clone has its own config object so mutations via
354
+ * {@link override}, {@link addInclude}, {@link addExclude}, and
355
+ * {@link addCoverageExclude} do not affect the original.
356
+ *
357
+ * @returns A new {@link VitestProject} with the same configuration
358
+ */
359
+ clone(): VitestProject;
360
+ /**
361
+ * Merges additional configuration over the current config.
362
+ *
363
+ * @remarks
364
+ * The {@link VitestProjectOptions.name | name} and
365
+ * {@link VitestProjectOptions.include | include} fields are preserved
366
+ * and cannot be overridden.
367
+ *
368
+ * @param config - Partial configuration to merge
369
+ * @returns `this` for chaining
370
+ */
371
+ override(config: Partial<TestProjectInlineConfiguration>): this;
372
+ /**
373
+ * Appends glob patterns to the test include list.
374
+ *
375
+ * @param patterns - Glob patterns to add
376
+ * @returns `this` for chaining
377
+ */
378
+ addInclude(...patterns: string[]): this;
379
+ /**
380
+ * Appends glob patterns to the test exclude list.
381
+ *
382
+ * @param patterns - Glob patterns to add
383
+ * @returns `this` for chaining
384
+ */
385
+ addExclude(...patterns: string[]): this;
386
+ /**
387
+ * Appends glob patterns to the coverage exclusion list.
388
+ *
389
+ * @remarks
390
+ * These patterns are exposed via {@link coverageExcludes} for the
391
+ * workspace-level coverage configuration to consume.
392
+ *
393
+ * @param patterns - Glob patterns to exclude from coverage
394
+ * @returns `this` for chaining
395
+ */
396
+ addCoverageExclude(...patterns: string[]): this;
276
397
  /**
277
398
  * Creates a unit test project with sensible defaults.
278
399
  *
@@ -315,6 +436,28 @@ export declare class VitestProject {
315
436
  * ```
316
437
  */
317
438
  static e2e(options: VitestProjectOptions): VitestProject;
439
+ /**
440
+ * Creates an integration test project with sensible defaults.
441
+ *
442
+ * @remarks
443
+ * Defaults applied: `extends: true`, `environment: "node"`,
444
+ * `testTimeout: 60_000`, `hookTimeout: 30_000`,
445
+ * `maxConcurrency: clamp(floor(cpus / 2), 1, 8)`.
446
+ *
447
+ * @param options - Project options (the `kind` field is forced to `"int"`)
448
+ * @returns A new {@link VitestProject} configured for integration tests
449
+ *
450
+ * @example
451
+ * ```typescript
452
+ * import { VitestProject } from "@savvy-web/vitest";
453
+ *
454
+ * const project = VitestProject.int({
455
+ * name: "@savvy-web/my-lib:int",
456
+ * include: ["__test__/integration/**\/*.int.test.ts"],
457
+ * });
458
+ * ```
459
+ */
460
+ static int(options: VitestProjectOptions): VitestProject;
318
461
  /**
319
462
  * Creates a custom test project with no preset defaults beyond `extends: true`.
320
463
  *
@@ -351,7 +494,7 @@ export declare class VitestProject {
351
494
  *
352
495
  * @public
353
496
  */
354
- export declare type VitestProjectKind = "unit" | "e2e" | (string & {});
497
+ export declare type VitestProjectKind = "unit" | "e2e" | "int" | (string & {});
355
498
 
356
499
  /**
357
500
  * Options for constructing a {@link VitestProject}.
package/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { cpus } from "node:os";
3
3
  import { join, relative } from "node:path";
4
+ import { AgentPlugin } from "vitest-agent-reporter";
4
5
  import { getWorkspaceManagerRoot, getWorkspacePackagePaths } from "workspace-tools";
5
6
  class VitestProject {
6
7
  #name;
7
8
  #kind;
8
9
  #config;
10
+ #coverageExcludes = [];
9
11
  constructor(options, defaults){
10
12
  this.#name = options.name;
11
13
  this.#kind = options.kind ?? "unit";
@@ -29,9 +31,75 @@ class VitestProject {
29
31
  get kind() {
30
32
  return this.#kind;
31
33
  }
34
+ get coverageExcludes() {
35
+ return this.#coverageExcludes;
36
+ }
32
37
  toConfig() {
33
38
  return this.#config;
34
39
  }
40
+ clone() {
41
+ const { test, ...rest } = this.#config;
42
+ const cloned = new VitestProject({
43
+ name: this.#name,
44
+ include: test?.include ?? [],
45
+ kind: this.#kind
46
+ }, {});
47
+ cloned.#config = {
48
+ ...rest,
49
+ test: test ? {
50
+ ...test
51
+ } : void 0
52
+ };
53
+ cloned.#coverageExcludes.push(...this.#coverageExcludes);
54
+ return cloned;
55
+ }
56
+ override(config) {
57
+ const { test: overrideTest, ...overrideRest } = config;
58
+ const { test: existingTest, ...existingRest } = this.#config;
59
+ this.#config = {
60
+ ...existingRest,
61
+ ...overrideRest,
62
+ test: {
63
+ ...existingTest,
64
+ ...overrideTest,
65
+ name: this.#name,
66
+ include: existingTest?.include
67
+ }
68
+ };
69
+ return this;
70
+ }
71
+ addInclude(...patterns) {
72
+ const { test: existingTest, ...rest } = this.#config;
73
+ this.#config = {
74
+ ...rest,
75
+ test: {
76
+ ...existingTest,
77
+ include: [
78
+ ...existingTest?.include ?? [],
79
+ ...patterns
80
+ ]
81
+ }
82
+ };
83
+ return this;
84
+ }
85
+ addExclude(...patterns) {
86
+ const { test: existingTest, ...rest } = this.#config;
87
+ this.#config = {
88
+ ...rest,
89
+ test: {
90
+ ...existingTest,
91
+ exclude: [
92
+ ...existingTest?.exclude ?? [],
93
+ ...patterns
94
+ ]
95
+ }
96
+ };
97
+ return this;
98
+ }
99
+ addCoverageExclude(...patterns) {
100
+ this.#coverageExcludes.push(...patterns);
101
+ return this;
102
+ }
35
103
  static unit(options) {
36
104
  return new VitestProject({
37
105
  ...options,
@@ -56,6 +124,20 @@ class VitestProject {
56
124
  }
57
125
  });
58
126
  }
127
+ static int(options) {
128
+ const concurrency = Math.max(1, Math.min(8, Math.floor(cpus().length / 2)));
129
+ return new VitestProject({
130
+ ...options,
131
+ kind: "int"
132
+ }, {
133
+ test: {
134
+ environment: "node",
135
+ testTimeout: 60000,
136
+ hookTimeout: 30000,
137
+ maxConcurrency: concurrency
138
+ }
139
+ });
140
+ }
59
141
  static custom(kind, options) {
60
142
  return new VitestProject({
61
143
  ...options,
@@ -64,13 +146,52 @@ class VitestProject {
64
146
  }
65
147
  }
66
148
  class VitestConfig {
67
- static DEFAULT_THRESHOLD = 80;
149
+ static DEFAULT_COVERAGE_EXCLUDE = [
150
+ "**/*.{test,spec}.{ts,tsx,js,jsx}",
151
+ "**/__test__/**",
152
+ "**/generated/**"
153
+ ];
154
+ static COVERAGE_LEVELS = Object.freeze({
155
+ none: {
156
+ lines: 0,
157
+ branches: 0,
158
+ functions: 0,
159
+ statements: 0
160
+ },
161
+ basic: {
162
+ lines: 50,
163
+ branches: 50,
164
+ functions: 50,
165
+ statements: 50
166
+ },
167
+ standard: {
168
+ lines: 70,
169
+ branches: 65,
170
+ functions: 70,
171
+ statements: 70
172
+ },
173
+ strict: {
174
+ lines: 80,
175
+ branches: 75,
176
+ functions: 80,
177
+ statements: 80
178
+ },
179
+ full: {
180
+ lines: 90,
181
+ branches: 85,
182
+ functions: 90,
183
+ statements: 90
184
+ }
185
+ });
68
186
  static cachedProjects = null;
69
187
  static cachedVitestProjects = null;
70
- static create(callback, options) {
71
- const specificProject = VitestConfig.getSpecificProject();
188
+ static async create(options, postProcess) {
189
+ const specificProjects = VitestConfig.getSpecificProjects();
72
190
  const { projects, vitestProjects } = VitestConfig.discoverWorkspaceProjects();
73
- const coverage = VitestConfig.getCoverageConfig(specificProject, projects, options);
191
+ const thresholds = VitestConfig.resolveThresholds(options);
192
+ const coverageConfig = VitestConfig.getCoverageConfig(specificProjects, projects, options);
193
+ const workingProjects = vitestProjects.map((p)=>p.clone());
194
+ VitestConfig.applyKindOverrides(workingProjects, options);
74
195
  const isCI = Boolean(process.env.GITHUB_ACTIONS);
75
196
  const reporters = isCI ? [
76
197
  "default",
@@ -78,20 +199,76 @@ class VitestConfig {
78
199
  ] : [
79
200
  "default"
80
201
  ];
81
- return callback({
82
- projects: vitestProjects,
83
- coverage,
84
- reporters,
85
- isCI
86
- });
202
+ let config = {
203
+ test: {
204
+ reporters,
205
+ projects: workingProjects.map((p)=>p.toConfig()),
206
+ ...options?.pool ? {
207
+ pool: options.pool
208
+ } : {},
209
+ coverage: {
210
+ provider: "v8",
211
+ ...coverageConfig,
212
+ enabled: true
213
+ }
214
+ }
215
+ };
216
+ if (options?.agentReporter !== false) {
217
+ const agentOpts = "object" == typeof options?.agentReporter ? options.agentReporter : {};
218
+ const plugin = AgentPlugin({
219
+ strategy: "own",
220
+ ...agentOpts,
221
+ reporter: {
222
+ coverageThresholds: {
223
+ ...thresholds
224
+ },
225
+ ...agentOpts.reporter
226
+ }
227
+ });
228
+ config.plugins = [
229
+ plugin
230
+ ];
231
+ }
232
+ if (postProcess) {
233
+ const result = postProcess(config);
234
+ if (void 0 !== result) config = result;
235
+ }
236
+ return config;
237
+ }
238
+ static applyKindOverrides(vitestProjects, options) {
239
+ if (!options) return;
240
+ const kindOptions = {
241
+ unit: options.unit,
242
+ e2e: options.e2e,
243
+ int: options.int
244
+ };
245
+ for (const [kind, override] of Object.entries(kindOptions)){
246
+ if (void 0 === override) continue;
247
+ const projectsOfKind = vitestProjects.filter((p)=>p.kind === kind);
248
+ if ("function" == typeof override) {
249
+ const map = new Map();
250
+ for (const p of projectsOfKind)map.set(p.name, p);
251
+ override(map);
252
+ } else for (const p of projectsOfKind)p.override({
253
+ test: override
254
+ });
255
+ }
87
256
  }
88
- static getSpecificProject() {
257
+ static getSpecificProjects() {
89
258
  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;
259
+ const projects = [];
260
+ for(let i = 0; i < args.length; i++){
261
+ const arg = args[i];
262
+ if (arg.startsWith("--project=")) {
263
+ const value = arg.split("=")[1];
264
+ if (value) projects.push(value);
265
+ } else if ("--project" === arg && i + 1 < args.length) {
266
+ const value = args[i + 1];
267
+ if (value) projects.push(value);
268
+ i++;
269
+ }
270
+ }
271
+ return projects;
95
272
  }
96
273
  static getPackageNameFromPath(packagePath) {
97
274
  try {
@@ -108,9 +285,26 @@ class VitestConfig {
108
285
  return false;
109
286
  }
110
287
  }
288
+ static SETUP_FILE_EXTENSIONS = [
289
+ "ts",
290
+ "tsx",
291
+ "js",
292
+ "jsx"
293
+ ];
294
+ static detectSetupFile(packagePath) {
295
+ for (const ext of VitestConfig.SETUP_FILE_EXTENSIONS){
296
+ const candidate = join(packagePath, `vitest.setup.${ext}`);
297
+ try {
298
+ const stat = statSync(candidate);
299
+ if (stat.isFile()) return `vitest.setup.${ext}`;
300
+ } catch {}
301
+ }
302
+ return null;
303
+ }
111
304
  static scanForTestFiles(dirPath) {
112
305
  let hasUnit = false;
113
306
  let hasE2e = false;
307
+ let hasInt = false;
114
308
  try {
115
309
  const entries = readdirSync(dirPath, {
116
310
  withFileTypes: true
@@ -120,16 +314,19 @@ class VitestConfig {
120
314
  const sub = VitestConfig.scanForTestFiles(join(dirPath, entry.name));
121
315
  hasUnit = hasUnit || sub.hasUnit;
122
316
  hasE2e = hasE2e || sub.hasE2e;
317
+ hasInt = hasInt || sub.hasInt;
123
318
  } 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;
319
+ if (/\.e2e\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) hasE2e = true;
320
+ else if (/\.int\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) hasInt = true;
321
+ else if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) hasUnit = true;
126
322
  }
127
- if (hasUnit && hasE2e) break;
323
+ if (hasUnit && hasE2e && hasInt) break;
128
324
  }
129
325
  } catch {}
130
326
  return {
131
327
  hasUnit,
132
- hasE2e
328
+ hasE2e,
329
+ hasInt
133
330
  };
134
331
  }
135
332
  static buildIncludes(srcGlob, testGlob, pattern) {
@@ -139,6 +336,21 @@ class VitestConfig {
139
336
  if (testGlob) includes.push(`${testGlob}/**/${pattern}`);
140
337
  return includes;
141
338
  }
339
+ static TEST_DIR_EXCLUSIONS = [
340
+ "__test__/fixtures/**",
341
+ "__test__/utils/**",
342
+ "__test__/unit/fixtures/**",
343
+ "__test__/unit/utils/**",
344
+ "__test__/e2e/fixtures/**",
345
+ "__test__/e2e/utils/**",
346
+ "__test__/int/fixtures/**",
347
+ "__test__/int/utils/**",
348
+ "__test__/integration/fixtures/**",
349
+ "__test__/integration/utils/**"
350
+ ];
351
+ static buildTestDirExclusions(prefix) {
352
+ return VitestConfig.TEST_DIR_EXCLUSIONS.map((pattern)=>`${prefix}${pattern}`);
353
+ }
142
354
  static discoverWorkspaceProjects() {
143
355
  if (VitestConfig.cachedProjects && VitestConfig.cachedVitestProjects) return {
144
356
  projects: VitestConfig.cachedProjects,
@@ -161,32 +373,83 @@ class VitestConfig {
161
373
  const srcScan = VitestConfig.scanForTestFiles(srcDirPath);
162
374
  const testScan = hasTestDir ? VitestConfig.scanForTestFiles(testDirPath) : {
163
375
  hasUnit: false,
164
- hasE2e: false
376
+ hasE2e: false,
377
+ hasInt: false
165
378
  };
166
379
  const hasUnit = srcScan.hasUnit || testScan.hasUnit;
167
380
  const hasE2e = srcScan.hasE2e || testScan.hasE2e;
168
- const hasBoth = hasUnit && hasE2e;
381
+ const hasInt = srcScan.hasInt || testScan.hasInt;
382
+ const kindCount = [
383
+ hasUnit,
384
+ hasE2e,
385
+ hasInt
386
+ ].filter(Boolean).length;
387
+ const shouldSuffix = kindCount >= 2;
169
388
  const prefix = "." === relativePath ? "" : `${relativePath}/`;
170
389
  const srcGlob = `${prefix}src`;
171
390
  const testGlob = hasTestDir ? `${prefix}__test__` : null;
391
+ const testDirExcludes = hasTestDir ? VitestConfig.buildTestDirExclusions(prefix) : [];
392
+ const setupFile = VitestConfig.detectSetupFile(pkgPath);
393
+ const setupFiles = setupFile ? [
394
+ `${prefix}${setupFile}`
395
+ ] : void 0;
172
396
  if (hasUnit) vitestProjects.push(VitestProject.unit({
173
- name: hasBoth ? `${packageName}:unit` : packageName,
174
- include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.ts"),
397
+ name: shouldSuffix ? `${packageName}:unit` : packageName,
398
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.{ts,tsx,js,jsx}"),
175
399
  overrides: {
176
400
  test: {
401
+ ...setupFiles ? {
402
+ setupFiles
403
+ } : {},
177
404
  exclude: [
178
- "**/*.e2e.{test,spec}.ts"
405
+ "**/*.e2e.{test,spec}.*",
406
+ "**/*.int.{test,spec}.*",
407
+ ...testDirExcludes
179
408
  ]
180
409
  }
181
410
  }
182
411
  }));
183
412
  if (hasE2e) vitestProjects.push(VitestProject.e2e({
184
- name: hasBoth ? `${packageName}:e2e` : packageName,
185
- include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.e2e.{test,spec}.ts")
413
+ name: shouldSuffix ? `${packageName}:e2e` : packageName,
414
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.e2e.{test,spec}.{ts,tsx,js,jsx}"),
415
+ overrides: {
416
+ test: {
417
+ ...setupFiles ? {
418
+ setupFiles
419
+ } : {},
420
+ exclude: [
421
+ ...testDirExcludes
422
+ ]
423
+ }
424
+ }
425
+ }));
426
+ if (hasInt) vitestProjects.push(VitestProject.int({
427
+ name: shouldSuffix ? `${packageName}:int` : packageName,
428
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.int.{test,spec}.{ts,tsx,js,jsx}"),
429
+ overrides: {
430
+ test: {
431
+ ...setupFiles ? {
432
+ setupFiles
433
+ } : {},
434
+ exclude: [
435
+ ...testDirExcludes
436
+ ]
437
+ }
438
+ }
186
439
  }));
187
- if (!hasUnit && !hasE2e) vitestProjects.push(VitestProject.unit({
440
+ if (!hasUnit && !hasE2e && !hasInt) vitestProjects.push(VitestProject.unit({
188
441
  name: packageName,
189
- include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.ts")
442
+ include: VitestConfig.buildIncludes(srcGlob, testGlob, "*.{test,spec}.{ts,tsx,js,jsx}"),
443
+ overrides: {
444
+ test: {
445
+ ...setupFiles ? {
446
+ setupFiles
447
+ } : {},
448
+ exclude: [
449
+ ...testDirExcludes
450
+ ]
451
+ }
452
+ }
190
453
  }));
191
454
  }
192
455
  VitestConfig.cachedProjects = projects;
@@ -196,28 +459,36 @@ class VitestConfig {
196
459
  vitestProjects
197
460
  };
198
461
  }
199
- static getCoverageConfig(specificProject, projects, options) {
462
+ static resolveThresholds(options) {
463
+ if (options?.coverage === void 0) return {
464
+ ...VitestConfig.COVERAGE_LEVELS.strict
465
+ };
466
+ if ("string" == typeof options.coverage) return {
467
+ ...VitestConfig.COVERAGE_LEVELS[options.coverage]
468
+ };
469
+ return {
470
+ ...options.coverage
471
+ };
472
+ }
473
+ static getCoverageConfig(specificProjects, projects, options) {
200
474
  const exclude = [
201
- "**/*.{test,spec}.ts"
475
+ ...VitestConfig.DEFAULT_COVERAGE_EXCLUDE,
476
+ ...options?.coverageExclude ?? []
202
477
  ];
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
- };
478
+ const thresholds = VitestConfig.resolveThresholds(options);
210
479
  const toSrcGlob = (relPath)=>{
211
480
  const prefix = "." === relPath ? "" : `${relPath}/`;
212
481
  return `${prefix}src/**/*.ts`;
213
482
  };
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
- ],
483
+ if (specificProjects.length > 0) {
484
+ const includes = [];
485
+ for (const sp of specificProjects){
486
+ const baseName = sp.replace(/:(unit|e2e|int)$/, "");
487
+ const relPath = projects[baseName];
488
+ if (void 0 !== relPath) includes.push(toSrcGlob(relPath));
489
+ }
490
+ if (includes.length > 0) return {
491
+ include: includes,
221
492
  exclude,
222
493
  thresholds
223
494
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/vitest",
3
- "version": "0.2.2",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "Vitest utility functions for Silk Suite deployment system",
6
6
  "homepage": "https://github.com/savvy-web/vitest#readme",
@@ -25,11 +25,12 @@
25
25
  "workspace-tools": "^0.41.0"
26
26
  },
27
27
  "peerDependencies": {
28
- "@types/node": "^25.2.0",
28
+ "@types/node": "^25.5.0",
29
29
  "@typescript/native-preview": "^7.0.0-dev.20260124.1",
30
- "@vitest/coverage-v8": "^4.0.18",
30
+ "@vitest/coverage-v8": "^4.1.0",
31
31
  "typescript": "^5.9.3",
32
- "vitest": "^4.0.18"
32
+ "vitest": "^4.1.0",
33
+ "vitest-agent-reporter": "^1.0.0"
33
34
  },
34
35
  "peerDependenciesMeta": {
35
36
  "@types/node": {
@@ -46,6 +47,9 @@
46
47
  },
47
48
  "vitest": {
48
49
  "optional": false
50
+ },
51
+ "vitest-agent-reporter": {
52
+ "optional": false
49
53
  }
50
54
  },
51
55
  "files": [