@flakiness/vitest 0.1.0-alpha.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 +241 -0
- package/lib/reporter.js +376 -0
- package/package.json +33 -0
- package/types/src/reporter.d.ts +65 -0
- package/types/src/reporter.d.ts.map +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Degu Labs, Inc
|
|
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,241 @@
|
|
|
1
|
+
# Flakiness.io Vitest Reporter
|
|
2
|
+
|
|
3
|
+
A custom Vitest reporter that generates Flakiness Reports from your Vitest test runs. The reporter automatically converts Vitest test results into the standardized [Flakiness JSON format](https://github.com/flakiness/flakiness-report), capturing test outcomes, system utilization, and environment information.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Requirements](#requirements)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [Uploading Reports](#uploading-reports)
|
|
11
|
+
- [Viewing Reports](#viewing-reports)
|
|
12
|
+
- [Features](#features)
|
|
13
|
+
- [Test Location Tracking](#test-location-tracking)
|
|
14
|
+
- [Duplicate Test Detection](#duplicate-test-detection)
|
|
15
|
+
- [Environment Detection](#environment-detection)
|
|
16
|
+
- [CI Integration](#ci-integration)
|
|
17
|
+
- [Configuration Options](#configuration-options)
|
|
18
|
+
- [`flakinessProject?: string`](#flakinessproject-string)
|
|
19
|
+
- [`endpoint?: string`](#endpoint-string)
|
|
20
|
+
- [`token?: string`](#token-string)
|
|
21
|
+
- [`outputFolder?: string`](#outputfolder-string)
|
|
22
|
+
- [`open?: 'always' | 'never' | 'on-failure'`](#open-always--never--on-failure)
|
|
23
|
+
- [`disableUpload?: boolean`](#disableupload-boolean)
|
|
24
|
+
- [Environment Variables](#environment-variables)
|
|
25
|
+
- [Example Configuration](#example-configuration)
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Vitest 4.0 or higher
|
|
30
|
+
- Node.js project with a git repository (for commit information)
|
|
31
|
+
- Valid Flakiness.io access token (for uploads)
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @flakiness/vitest
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
Add the reporter to your `vitest.config.ts`:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { defineConfig } from 'vitest/config';
|
|
45
|
+
|
|
46
|
+
export default defineConfig({
|
|
47
|
+
test: {
|
|
48
|
+
includeTaskLocation: true,
|
|
49
|
+
reporters: [
|
|
50
|
+
['@flakiness/vitest']
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> **Note:** Setting `includeTaskLocation: true` is recommended to enable test locations the report.
|
|
57
|
+
|
|
58
|
+
Run your tests. The report will be automatically generated in the `./flakiness-report` folder:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx vitest run
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
View the interactive report:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx flakiness show ./flakiness-report
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Uploading Reports
|
|
71
|
+
|
|
72
|
+
Reports are automatically uploaded to Flakiness.io after test completion. Authentication can be done in two ways:
|
|
73
|
+
|
|
74
|
+
- **Access token**: Provide a token via the `token` option or the `FLAKINESS_ACCESS_TOKEN` environment variable.
|
|
75
|
+
- **GitHub OIDC**: When running in GitHub Actions, the reporter can authenticate using GitHub's OIDC token - no access token needed. This requires two conditions:
|
|
76
|
+
1. The `flakinessProject` option must be set to your Flakiness.io project identifier (`org/project`).
|
|
77
|
+
2. The Flakiness.io project must be bound to the GitHub repository that runs the GitHub Actions workflow.
|
|
78
|
+
|
|
79
|
+
If upload fails, the report is still available locally in the output folder.
|
|
80
|
+
|
|
81
|
+
## Viewing Reports
|
|
82
|
+
|
|
83
|
+
After test execution, you can view the report using:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx flakiness show ./flakiness-report
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Features
|
|
90
|
+
|
|
91
|
+
### Test Location Tracking
|
|
92
|
+
|
|
93
|
+
When `includeTaskLocation: true` is set in your Vitest config, the reporter records the exact file, line, and column for each test. This enables precise navigation from the Flakiness.io dashboard back to your source code.
|
|
94
|
+
|
|
95
|
+
### Duplicate Test Detection
|
|
96
|
+
|
|
97
|
+
Vitest allows multiple tests to share the same name:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
it('should work', () => { /* ... */ });
|
|
101
|
+
it('should work', () => { /* ... */ });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Flakiness.io relies on full test names (all parent suite names + test name) to construct test history, so each test must have a unique full name. The reporter detects duplicates, issues warnings, and marks them as failed with a descriptive error message. Rename your tests to have unique full names to resolve this.
|
|
105
|
+
|
|
106
|
+
### Environment Detection
|
|
107
|
+
|
|
108
|
+
For each Vitest project, the reporter creates a unique environment. If a project has a name, it will be used as the environment name in Flakiness.io. Tests run in different projects get separate history timelines.
|
|
109
|
+
|
|
110
|
+
Environment variables prefixed with `FK_ENV_` are automatically included in the environment metadata. The prefix is stripped and the key is converted to lowercase.
|
|
111
|
+
|
|
112
|
+
**Example:**
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export FK_ENV_DEPLOYMENT=staging
|
|
116
|
+
export FK_ENV_REGION=us-east-1
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This will result in the environment containing:
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"metadata": {
|
|
123
|
+
"deployment": "staging",
|
|
124
|
+
"region": "us-east-1"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Flakiness.io will create a dedicated history for tests executed in each unique environment. This means tests run with `FK_ENV_DEPLOYMENT=staging` will have a separate timeline from tests run with `FK_ENV_DEPLOYMENT=production`, allowing you to track flakiness patterns specific to each deployment environment.
|
|
130
|
+
|
|
131
|
+
### CI Integration
|
|
132
|
+
|
|
133
|
+
The reporter automatically detects CI environments and includes:
|
|
134
|
+
- CI run URLs (GitHub Actions, Azure DevOps, Jenkins, GitLab CI)
|
|
135
|
+
- Git commit information
|
|
136
|
+
- System environment data
|
|
137
|
+
|
|
138
|
+
## Configuration Options
|
|
139
|
+
|
|
140
|
+
The reporter accepts the following options:
|
|
141
|
+
|
|
142
|
+
### `flakinessProject?: string`
|
|
143
|
+
|
|
144
|
+
The Flakiness.io project identifier in `org/project` format. Used for GitHub OIDC authentication — when set, and the Flakiness.io project is bound to the GitHub repository running the workflow, the reporter authenticates uploads via GitHub Actions OIDC token with no access token required.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
reporters: [
|
|
148
|
+
['@flakiness/vitest', { flakinessProject: 'my-org/my-project' }]
|
|
149
|
+
]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `endpoint?: string`
|
|
153
|
+
|
|
154
|
+
Custom Flakiness.io endpoint URL for uploading reports. Defaults to the `FLAKINESS_ENDPOINT` environment variable, or `https://flakiness.io` if not set.
|
|
155
|
+
|
|
156
|
+
Use this option to point to a custom or self-hosted Flakiness.io instance.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
reporters: [
|
|
160
|
+
['@flakiness/vitest', { endpoint: 'https://custom.flakiness.io' }]
|
|
161
|
+
]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `token?: string`
|
|
165
|
+
|
|
166
|
+
Access token for authenticating with Flakiness.io when uploading reports. Defaults to the `FLAKINESS_ACCESS_TOKEN` environment variable.
|
|
167
|
+
|
|
168
|
+
If no token is provided, reporter will attempt to authenticate using Github OIDC.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
reporters: [
|
|
172
|
+
['@flakiness/vitest', { token: 'your-access-token' }]
|
|
173
|
+
]
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### `outputFolder?: string`
|
|
177
|
+
|
|
178
|
+
Directory path where the Flakiness report will be written. Defaults to `flakiness-report` in the current working directory, or the `FLAKINESS_OUTPUT_DIR` environment variable if set.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
reporters: [
|
|
182
|
+
['@flakiness/vitest', { outputFolder: './test-results/flakiness' }]
|
|
183
|
+
]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `open?: 'always' | 'never' | 'on-failure'`
|
|
187
|
+
|
|
188
|
+
Controls when the report viewer should automatically open in your browser after test completion.
|
|
189
|
+
|
|
190
|
+
- **`'on-failure'`** (default): Opens the report only if tests failed and running in an interactive terminal (not in CI)
|
|
191
|
+
- **`'always'`**: Always opens the report after test completion (when running in an interactive terminal)
|
|
192
|
+
- **`'never'`**: Never automatically opens the report
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
reporters: [
|
|
196
|
+
['@flakiness/vitest', { open: 'always' }]
|
|
197
|
+
]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `disableUpload?: boolean`
|
|
201
|
+
|
|
202
|
+
When set to `true`, prevents uploading the report to Flakiness.io. The report is still generated locally. Can also be controlled via the `FLAKINESS_DISABLE_UPLOAD` environment variable.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
reporters: [
|
|
206
|
+
['@flakiness/vitest', { disableUpload: true }]
|
|
207
|
+
]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Environment Variables
|
|
211
|
+
|
|
212
|
+
The reporter respects the following environment variables:
|
|
213
|
+
|
|
214
|
+
- **`FLAKINESS_ACCESS_TOKEN`**: Access token for Flakiness.io uploads (equivalent to `token` option)
|
|
215
|
+
- **`FLAKINESS_ENDPOINT`**: Custom Flakiness.io endpoint URL (equivalent to `endpoint` option)
|
|
216
|
+
- **`FLAKINESS_OUTPUT_DIR`**: Output directory for reports (equivalent to `outputFolder` option)
|
|
217
|
+
- **`FLAKINESS_DISABLE_UPLOAD`**: When set, disables report uploads (equivalent to `disableUpload` option)
|
|
218
|
+
|
|
219
|
+
## Example Configuration
|
|
220
|
+
|
|
221
|
+
Here's a complete example with all options:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { defineConfig } from 'vitest/config';
|
|
225
|
+
|
|
226
|
+
export default defineConfig({
|
|
227
|
+
test: {
|
|
228
|
+
includeTaskLocation: true,
|
|
229
|
+
reporters: [
|
|
230
|
+
['@flakiness/vitest', {
|
|
231
|
+
flakinessProject: 'my-org/my-project',
|
|
232
|
+
endpoint: process.env.FLAKINESS_ENDPOINT,
|
|
233
|
+
token: process.env.FLAKINESS_ACCESS_TOKEN,
|
|
234
|
+
outputFolder: './flakiness-report',
|
|
235
|
+
open: 'on-failure',
|
|
236
|
+
disableUpload: false,
|
|
237
|
+
}]
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
package/lib/reporter.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { FlakinessReport as FK } from "@flakiness/flakiness-report";
|
|
2
|
+
import { CIUtils, CPUUtilization, GitWorktree, RAMUtilization, ReportUtils, showReport, uploadReport, writeReport } from "@flakiness/sdk";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
class FKVitestReporter {
|
|
8
|
+
constructor(_options) {
|
|
9
|
+
this._options = _options;
|
|
10
|
+
}
|
|
11
|
+
_impl;
|
|
12
|
+
_logger = {
|
|
13
|
+
warn: (txt) => console.warn(chalk.yellow(txt)),
|
|
14
|
+
error: (txt) => console.error(chalk.red(txt)),
|
|
15
|
+
log: (txt) => console.log(txt)
|
|
16
|
+
};
|
|
17
|
+
setLoggerForTest(logger) {
|
|
18
|
+
this._logger = logger;
|
|
19
|
+
}
|
|
20
|
+
onUserConsoleLog(log) {
|
|
21
|
+
this._impl?.onUserConsoleLog(log);
|
|
22
|
+
}
|
|
23
|
+
onInit(vitest) {
|
|
24
|
+
this._impl = ReporterImpl.create(vitest.config.root, this._options, this._logger);
|
|
25
|
+
this._impl?.onInit(vitest);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Called when annotation is added via the `task.annotate` API.
|
|
29
|
+
*/
|
|
30
|
+
onTestCaseAnnotate(testCase, annotation) {
|
|
31
|
+
this._impl?.onTestCaseAnnotate(testCase, annotation);
|
|
32
|
+
}
|
|
33
|
+
onTestRunStart() {
|
|
34
|
+
this._impl?.onTestRunStart();
|
|
35
|
+
}
|
|
36
|
+
async onTestCaseResult(testCase) {
|
|
37
|
+
this._impl?.onTestCaseResult(testCase);
|
|
38
|
+
}
|
|
39
|
+
async onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
40
|
+
this._impl?.onTestRunEnd(testModules, unhandledErrors, reason);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
class ReporterImpl {
|
|
44
|
+
constructor(_worktree, _commitId, _options, _logger) {
|
|
45
|
+
this._worktree = _worktree;
|
|
46
|
+
this._commitId = _commitId;
|
|
47
|
+
this._options = _options;
|
|
48
|
+
this._logger = _logger;
|
|
49
|
+
this._sampleSystem = this._sampleSystem.bind(this);
|
|
50
|
+
}
|
|
51
|
+
_telemetryTimer;
|
|
52
|
+
_cpuUtilization = new CPUUtilization({ precision: 10 });
|
|
53
|
+
_ramUtilization = new RAMUtilization({ precision: 10 });
|
|
54
|
+
_startTimestamp = Date.now();
|
|
55
|
+
_testCaseIdToTest = /* @__PURE__ */ new Map();
|
|
56
|
+
_testToTestCaseId = /* @__PURE__ */ new Map();
|
|
57
|
+
_stdio = /* @__PURE__ */ new Map();
|
|
58
|
+
_annotations = /* @__PURE__ */ new Map();
|
|
59
|
+
// In Vitest, all projects MUST HAVE UNIQUE NAMES.
|
|
60
|
+
// So we create environments per project name.
|
|
61
|
+
_environments = [];
|
|
62
|
+
_allSuites = /* @__PURE__ */ new Map();
|
|
63
|
+
_fileSuites = /* @__PURE__ */ new Map();
|
|
64
|
+
_configPath;
|
|
65
|
+
static create(rootPath, options, logger) {
|
|
66
|
+
let commitId;
|
|
67
|
+
let worktree;
|
|
68
|
+
try {
|
|
69
|
+
worktree = GitWorktree.create(rootPath);
|
|
70
|
+
commitId = worktree.headCommitId();
|
|
71
|
+
return new ReporterImpl(worktree, commitId, options, logger);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
logger.warn(`[flakiness.io] Failed to fetch commit info - is this a git repo?`);
|
|
74
|
+
logger.error(`[flakiness.io] Report is NOT generated.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
_sampleSystem() {
|
|
79
|
+
this._cpuUtilization.sample();
|
|
80
|
+
this._ramUtilization.sample();
|
|
81
|
+
this._telemetryTimer = setTimeout(this._sampleSystem, 1e3);
|
|
82
|
+
}
|
|
83
|
+
onInit(vitest) {
|
|
84
|
+
this._configPath = vitest.config.config;
|
|
85
|
+
}
|
|
86
|
+
onUserConsoleLog(log) {
|
|
87
|
+
if (!log.taskId)
|
|
88
|
+
return;
|
|
89
|
+
let entries = this._stdio.get(log.taskId);
|
|
90
|
+
if (!entries) {
|
|
91
|
+
entries = [];
|
|
92
|
+
this._stdio.set(log.taskId, entries);
|
|
93
|
+
}
|
|
94
|
+
entries.push(log);
|
|
95
|
+
}
|
|
96
|
+
onTestCaseAnnotate(testCase, annotation) {
|
|
97
|
+
let entries = this._annotations.get(testCase.id);
|
|
98
|
+
if (!entries) {
|
|
99
|
+
entries = [];
|
|
100
|
+
this._annotations.set(testCase.id, entries);
|
|
101
|
+
}
|
|
102
|
+
entries.push(annotation);
|
|
103
|
+
}
|
|
104
|
+
onTestRunStart() {
|
|
105
|
+
this._startTimestamp = Date.now();
|
|
106
|
+
this._sampleSystem();
|
|
107
|
+
}
|
|
108
|
+
_ensureFKSuite(p) {
|
|
109
|
+
let suite = this._allSuites.get(p.id);
|
|
110
|
+
if (suite)
|
|
111
|
+
return suite;
|
|
112
|
+
if ("name" in p) {
|
|
113
|
+
const parent = this._ensureFKSuite(p.parent);
|
|
114
|
+
suite = {
|
|
115
|
+
type: "suite",
|
|
116
|
+
title: p.name,
|
|
117
|
+
location: p.location ? {
|
|
118
|
+
file: this._worktree.gitPath(p.module.moduleId),
|
|
119
|
+
column: p.location.column,
|
|
120
|
+
line: p.location.line
|
|
121
|
+
} : void 0
|
|
122
|
+
};
|
|
123
|
+
parent.suites ??= [];
|
|
124
|
+
parent.suites.push(suite);
|
|
125
|
+
} else {
|
|
126
|
+
suite = {
|
|
127
|
+
type: "file",
|
|
128
|
+
title: p.relativeModuleId,
|
|
129
|
+
location: {
|
|
130
|
+
file: this._worktree.gitPath(p.moduleId),
|
|
131
|
+
column: 0,
|
|
132
|
+
line: 0
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
this._fileSuites.set(p.id, suite);
|
|
136
|
+
}
|
|
137
|
+
this._allSuites.set(p.id, suite);
|
|
138
|
+
return suite;
|
|
139
|
+
}
|
|
140
|
+
_ensureTest(testCase) {
|
|
141
|
+
let fkTest = this._testCaseIdToTest.get(testCase.id);
|
|
142
|
+
if (!fkTest) {
|
|
143
|
+
fkTest = {
|
|
144
|
+
attempts: [],
|
|
145
|
+
title: testCase.name,
|
|
146
|
+
location: testCase.location ? {
|
|
147
|
+
file: this._worktree.gitPath(testCase.module.moduleId),
|
|
148
|
+
column: testCase.location.column,
|
|
149
|
+
line: testCase.location.line
|
|
150
|
+
} : void 0
|
|
151
|
+
};
|
|
152
|
+
this._testToTestCaseId.set(fkTest, testCase.id);
|
|
153
|
+
this._testCaseIdToTest.set(testCase.id, fkTest);
|
|
154
|
+
const parent = this._ensureFKSuite(testCase.parent);
|
|
155
|
+
parent.tests ??= [];
|
|
156
|
+
parent.tests.push(fkTest);
|
|
157
|
+
}
|
|
158
|
+
return fkTest;
|
|
159
|
+
}
|
|
160
|
+
_ensureEnvironmentIdx(testCase) {
|
|
161
|
+
let idx = this._environments.findIndex((env) => env.name === testCase.project.name);
|
|
162
|
+
if (idx === -1) {
|
|
163
|
+
idx = this._environments.length;
|
|
164
|
+
this._environments.push(ReportUtils.createEnvironment({
|
|
165
|
+
name: testCase.project.name
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
return idx;
|
|
169
|
+
}
|
|
170
|
+
_errorLocation(stacks, testFile) {
|
|
171
|
+
const frame = stacks?.find((frame2) => this._worktree.gitPath(frame2.file) === testFile) ?? stacks?.find((frame2) => !frame2.file.includes("node_modules"));
|
|
172
|
+
return frame ? {
|
|
173
|
+
file: this._worktree.gitPath(frame.file),
|
|
174
|
+
line: frame.line,
|
|
175
|
+
column: frame.column
|
|
176
|
+
} : void 0;
|
|
177
|
+
}
|
|
178
|
+
async onTestCaseResult(testCase) {
|
|
179
|
+
const environmentIdx = this._ensureEnvironmentIdx(testCase);
|
|
180
|
+
const fkTest = this._ensureTest(testCase);
|
|
181
|
+
const result = testCase.result();
|
|
182
|
+
if (result.state === "skipped") {
|
|
183
|
+
fkTest.attempts.push({
|
|
184
|
+
environmentIdx,
|
|
185
|
+
startTimestamp: Date.now(),
|
|
186
|
+
duration: 0,
|
|
187
|
+
status: "skipped",
|
|
188
|
+
expectedStatus: "skipped"
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const diag = testCase.diagnostic();
|
|
193
|
+
assert(diag, `Diagnostic must be present in finished test cases`);
|
|
194
|
+
;
|
|
195
|
+
const stdio = [];
|
|
196
|
+
let ts = diag.startTime;
|
|
197
|
+
for (const entry of this._stdio.get(testCase.id) ?? []) {
|
|
198
|
+
stdio.push({
|
|
199
|
+
text: entry.content,
|
|
200
|
+
stream: entry.type === "stdout" ? FK.STREAM_STDOUT : FK.STREAM_STDERR,
|
|
201
|
+
dts: entry.time - ts
|
|
202
|
+
});
|
|
203
|
+
ts = entry.time;
|
|
204
|
+
}
|
|
205
|
+
this._stdio.delete(testCase.id);
|
|
206
|
+
const testFile = this._worktree.gitPath(testCase.module.moduleId);
|
|
207
|
+
const errors = (result.errors ?? []).map((error) => ({
|
|
208
|
+
message: error.message,
|
|
209
|
+
stack: error.stack,
|
|
210
|
+
location: this._errorLocation(error.stacks, testFile)
|
|
211
|
+
}));
|
|
212
|
+
const annotations = (this._annotations.get(testCase.id) ?? []).map((annotation) => ({
|
|
213
|
+
type: annotation.type,
|
|
214
|
+
description: annotation.message,
|
|
215
|
+
location: annotation.location ? {
|
|
216
|
+
file: this._worktree.gitPath(annotation.location.file),
|
|
217
|
+
line: annotation.location.line,
|
|
218
|
+
column: annotation.location.column
|
|
219
|
+
} : void 0
|
|
220
|
+
}));
|
|
221
|
+
const expectedStatus = testCase.options.fails ? "failed" : "passed";
|
|
222
|
+
const oppositeStatus = expectedStatus === "failed" ? "passed" : "failed";
|
|
223
|
+
for (let i = 0; i < diag.retryCount; ++i) {
|
|
224
|
+
fkTest.attempts.push({
|
|
225
|
+
environmentIdx,
|
|
226
|
+
startTimestamp: diag.startTime,
|
|
227
|
+
duration: 0,
|
|
228
|
+
// retries have an opposite status from expected status to
|
|
229
|
+
// trigger retry.
|
|
230
|
+
status: oppositeStatus,
|
|
231
|
+
expectedStatus,
|
|
232
|
+
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
233
|
+
// However, vitest doesn't let us do so easily.
|
|
234
|
+
stdio,
|
|
235
|
+
errors,
|
|
236
|
+
annotations
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
fkTest.attempts.push({
|
|
240
|
+
environmentIdx,
|
|
241
|
+
startTimestamp: diag.startTime,
|
|
242
|
+
duration: diag.duration,
|
|
243
|
+
status: result.state === "failed" ? oppositeStatus : expectedStatus,
|
|
244
|
+
expectedStatus,
|
|
245
|
+
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
246
|
+
// However, vitest doesn't let us do so easily.
|
|
247
|
+
stdio,
|
|
248
|
+
errors,
|
|
249
|
+
annotations
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
_detectAndHandleTestDuplicates() {
|
|
253
|
+
const TITLE_SEPARATOR = crypto.randomBytes(128).toString("base64");
|
|
254
|
+
const testIdToTests = /* @__PURE__ */ new Map();
|
|
255
|
+
const testFullNames = /* @__PURE__ */ new Map();
|
|
256
|
+
function visitSuite(suite, parentTitles = []) {
|
|
257
|
+
parentTitles.push(suite.title);
|
|
258
|
+
for (const childSuite of suite.suites ?? [])
|
|
259
|
+
visitSuite(childSuite, parentTitles);
|
|
260
|
+
for (const test of suite.tests ?? []) {
|
|
261
|
+
const envs = Array.from(new Set(test.attempts.map((attempt) => attempt.environmentIdx ?? 0))).sort((a, b) => a - b);
|
|
262
|
+
const testId = [`[${envs.join(", ")}]`, ...parentTitles, test.title].join(TITLE_SEPARATOR);
|
|
263
|
+
testFullNames.set(test, [...parentTitles, test.title].join(" > "));
|
|
264
|
+
let tests = testIdToTests.get(testId);
|
|
265
|
+
if (!tests) {
|
|
266
|
+
tests = [];
|
|
267
|
+
testIdToTests.set(testId, tests);
|
|
268
|
+
}
|
|
269
|
+
tests.push(test);
|
|
270
|
+
}
|
|
271
|
+
parentTitles.pop();
|
|
272
|
+
}
|
|
273
|
+
for (const fileSuite of this._fileSuites.values())
|
|
274
|
+
visitSuite(fileSuite);
|
|
275
|
+
if (Array.from(testIdToTests.values()).every((tests) => tests.length <= 1))
|
|
276
|
+
return;
|
|
277
|
+
const warnMessages = [];
|
|
278
|
+
for (const [testId, tests] of testIdToTests) {
|
|
279
|
+
if (tests.length <= 1)
|
|
280
|
+
continue;
|
|
281
|
+
const testFullName = testFullNames.get(tests[0]);
|
|
282
|
+
const warnMessage = `${tests.length} tests: ${testFullName}`;
|
|
283
|
+
warnMessages.push(warnMessage);
|
|
284
|
+
const test = tests[0];
|
|
285
|
+
const envs = new Set(test.attempts.map((a) => a.environmentIdx ?? 0));
|
|
286
|
+
test.attempts = Array.from(envs).map((envIdx) => ({
|
|
287
|
+
environmentIdx: envIdx,
|
|
288
|
+
startTimestamp: Date.now(),
|
|
289
|
+
duration: 0,
|
|
290
|
+
expectedStatus: "passed",
|
|
291
|
+
status: "failed",
|
|
292
|
+
errors: [{
|
|
293
|
+
message: [
|
|
294
|
+
`Flakiness.io detected ${tests.length} tests with identical full name "${testFullName}"`,
|
|
295
|
+
`Please rename tests to ensure they all have unique full names.`
|
|
296
|
+
].join("\n")
|
|
297
|
+
}],
|
|
298
|
+
annotations: [{
|
|
299
|
+
type: "duplicates",
|
|
300
|
+
description: [
|
|
301
|
+
`Flakiness.io failed to process this test because there are ${tests.length} tests with`,
|
|
302
|
+
`identical full name: "${testFullName}".`,
|
|
303
|
+
`Please make sure that all your tests have unique full names.`
|
|
304
|
+
].join("\n")
|
|
305
|
+
}]
|
|
306
|
+
}));
|
|
307
|
+
for (let i = 1; i < tests.length; ++i)
|
|
308
|
+
tests[i].attempts = [];
|
|
309
|
+
}
|
|
310
|
+
if (warnMessages.length) {
|
|
311
|
+
this._logger.warn(`[flakiness.io] \u26A0 Detected test with duplicate names!`);
|
|
312
|
+
for (const warnMessage of warnMessages)
|
|
313
|
+
this._logger.warn(`[flakiness.io] - ${warnMessage}`);
|
|
314
|
+
this._logger.warn(`[flakiness.io] Please rename tests so that they all have unique full names.`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
318
|
+
clearTimeout(this._telemetryTimer);
|
|
319
|
+
this._cpuUtilization.sample();
|
|
320
|
+
this._ramUtilization.sample();
|
|
321
|
+
this._detectAndHandleTestDuplicates();
|
|
322
|
+
const duration = Date.now() - this._startTimestamp;
|
|
323
|
+
const report = ReportUtils.normalizeReport({
|
|
324
|
+
url: CIUtils.runUrl(),
|
|
325
|
+
flakinessProject: this._options.flakinessProject,
|
|
326
|
+
category: "vitest",
|
|
327
|
+
configPath: this._configPath ? this._worktree.gitPath(this._configPath) : void 0,
|
|
328
|
+
commitId: this._commitId,
|
|
329
|
+
environments: this._environments,
|
|
330
|
+
startTimestamp: this._startTimestamp,
|
|
331
|
+
duration,
|
|
332
|
+
suites: Array.from(this._fileSuites.values()),
|
|
333
|
+
unattributedErrors: unhandledErrors.map((error) => ({
|
|
334
|
+
message: error.message,
|
|
335
|
+
stack: error.stack,
|
|
336
|
+
location: this._errorLocation(error.stacks)
|
|
337
|
+
}))
|
|
338
|
+
});
|
|
339
|
+
await ReportUtils.collectSources(this._worktree, report);
|
|
340
|
+
this._cpuUtilization.enrich(report);
|
|
341
|
+
this._ramUtilization.enrich(report);
|
|
342
|
+
const outputFolder = this._options.outputFolder ?? path.join(
|
|
343
|
+
process.cwd(),
|
|
344
|
+
process.env.FLAKINESS_OUTPUT_DIR ?? "flakiness-report"
|
|
345
|
+
);
|
|
346
|
+
await writeReport(report, [], outputFolder);
|
|
347
|
+
const disableUpload = !!this._options.disableUpload || !!process.env.FLAKINESS_DISABLE_UPLOAD;
|
|
348
|
+
if (!disableUpload) {
|
|
349
|
+
await uploadReport(report, [], {
|
|
350
|
+
flakinessAccessToken: this._options.token,
|
|
351
|
+
flakinessEndpoint: this._options.endpoint,
|
|
352
|
+
logger: this._logger
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const openMode = this._options.open ?? "on-failure";
|
|
356
|
+
const shouldOpen = process.stdin.isTTY && !process.env.CI && (openMode === "always" || openMode === "on-failure" && reason === "failed");
|
|
357
|
+
if (shouldOpen) {
|
|
358
|
+
await showReport(outputFolder);
|
|
359
|
+
} else {
|
|
360
|
+
const defaultOutputFolder = path.join(process.cwd(), "flakiness-report");
|
|
361
|
+
const folder = defaultOutputFolder === outputFolder ? "" : path.relative(process.cwd(), outputFolder);
|
|
362
|
+
this._logger.log(`
|
|
363
|
+
To open last Flakiness report, run:
|
|
364
|
+
|
|
365
|
+
${chalk.cyan(`npx flakiness show ${folder}`)}
|
|
366
|
+
`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function dupeSuffix(dupeIndex) {
|
|
371
|
+
return ` \u2013 dupe #${dupeIndex}`;
|
|
372
|
+
}
|
|
373
|
+
export {
|
|
374
|
+
FKVitestReporter as default
|
|
375
|
+
};
|
|
376
|
+
//# sourceMappingURL=reporter.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flakiness/vitest",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [],
|
|
7
|
+
"author": "",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/flakiness/vitest"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^25.3.3",
|
|
16
|
+
"@vitest/utils": "^4.0.18",
|
|
17
|
+
"esbuild": "^0.27.3",
|
|
18
|
+
"kubik": "^0.24.0",
|
|
19
|
+
"tsx": "^4.21.0",
|
|
20
|
+
"typescript": "^5.9.3",
|
|
21
|
+
"vitest": "^4.0.18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@flakiness/flakiness-report": "^0.27.0",
|
|
25
|
+
"@flakiness/sdk": "^2.2.0",
|
|
26
|
+
"chalk": "^5.6.2"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "kubik build.mts",
|
|
30
|
+
"test": "vitest",
|
|
31
|
+
"test:run": "vitest run"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SerializedError, TestCase, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
|
|
2
|
+
import type { Reporter } from 'vitest/reporters';
|
|
3
|
+
interface UserConsoleLog {
|
|
4
|
+
content: string;
|
|
5
|
+
origin?: string;
|
|
6
|
+
browser?: boolean;
|
|
7
|
+
type: "stdout" | "stderr";
|
|
8
|
+
taskId?: string;
|
|
9
|
+
time: number;
|
|
10
|
+
size: number;
|
|
11
|
+
}
|
|
12
|
+
interface TestArtifactLocation {
|
|
13
|
+
/** Line number in the source file (1-indexed) */
|
|
14
|
+
line: number;
|
|
15
|
+
/** Column number in the line (1-indexed) */
|
|
16
|
+
column: number;
|
|
17
|
+
/** Path to the source file */
|
|
18
|
+
file: string;
|
|
19
|
+
}
|
|
20
|
+
interface TestAttachment {
|
|
21
|
+
/** MIME type of the attachment (e.g., 'image/png', 'text/plain') */
|
|
22
|
+
contentType?: string;
|
|
23
|
+
/** File system path to the attachment */
|
|
24
|
+
path?: string;
|
|
25
|
+
/** Inline attachment content as a string or raw binary data */
|
|
26
|
+
body?: string | Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
interface TestAnnotation {
|
|
29
|
+
message: string;
|
|
30
|
+
type: string;
|
|
31
|
+
location?: TestArtifactLocation;
|
|
32
|
+
attachment?: TestAttachment;
|
|
33
|
+
}
|
|
34
|
+
export type OpenMode = 'always' | 'never' | 'on-failure';
|
|
35
|
+
export type FKVitestReporterOptions = {
|
|
36
|
+
disableUpload?: boolean;
|
|
37
|
+
flakinessProject?: string;
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
token?: string;
|
|
40
|
+
outputFolder?: string;
|
|
41
|
+
open?: OpenMode;
|
|
42
|
+
};
|
|
43
|
+
export interface FKVitestLogger {
|
|
44
|
+
log(txt: string): void;
|
|
45
|
+
warn(txt: string): void;
|
|
46
|
+
error(txt: string): void;
|
|
47
|
+
}
|
|
48
|
+
export default class FKVitestReporter implements Reporter {
|
|
49
|
+
private _options;
|
|
50
|
+
private _impl?;
|
|
51
|
+
private _logger;
|
|
52
|
+
constructor(_options: FKVitestReporterOptions);
|
|
53
|
+
setLoggerForTest(logger: FKVitestLogger): void;
|
|
54
|
+
onUserConsoleLog(log: UserConsoleLog): void;
|
|
55
|
+
onInit(vitest: Vitest): void;
|
|
56
|
+
/**
|
|
57
|
+
* Called when annotation is added via the `task.annotate` API.
|
|
58
|
+
*/
|
|
59
|
+
onTestCaseAnnotate(testCase: TestCase, annotation: TestAnnotation): void;
|
|
60
|
+
onTestRunStart(): void;
|
|
61
|
+
onTestCaseResult(testCase: TestCase): Promise<void>;
|
|
62
|
+
onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=reporter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../../src/reporter.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAAa,MAAM,EAAE,MAAM,aAAa,CAAC;AAC9G,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAIjD,UAAU,cAAc;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACb;AACD,UAAU,oBAAoB;IAC7B,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;CACb;AACD,UAAU,cAAc;IACvB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;CAC3B;AACD,UAAU,cAAc;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAED,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,YAAY,CAAC;AAEzD,MAAM,MAAM,uBAAuB,GAAG;IACpC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB,CAAA;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,CAAC,OAAO,OAAO,gBAAiB,YAAW,QAAQ;IAQ3C,OAAO,CAAC,QAAQ;IAP5B,OAAO,CAAC,KAAK,CAAC,CAAe;IAC7B,OAAO,CAAC,OAAO,CAId;gBAEmB,QAAQ,EAAE,uBAAuB;IAGrD,gBAAgB,CAAC,MAAM,EAAE,cAAc;IAIvC,gBAAgB,CAAC,GAAG,EAAE,cAAc;IAIpC,MAAM,CAAC,MAAM,EAAE,MAAM;IAKtB;;MAEE;IACF,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,cAAc;IAIhE,cAAc;IAIR,gBAAgB,CAAC,QAAQ,EAAE,QAAQ;IAInC,YAAY,CAAC,WAAW,EAAE,aAAa,CAAC,UAAU,CAAC,EAAE,eAAe,EAAE,aAAa,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,gBAAgB;CAGrI"}
|