@flakiness/vitest 0.1.0-alpha.2 → 1.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/README.md +37 -14
- package/lib/reporter.js +157 -158
- package/package.json +2 -1
- package/types/src/reporter.d.ts +4 -31
- package/types/src/reporter.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A custom Vitest reporter that generates Flakiness Reports from your Vitest test
|
|
|
11
11
|
- [Viewing Reports](#viewing-reports)
|
|
12
12
|
- [Features](#features)
|
|
13
13
|
- [Test Location Tracking](#test-location-tracking)
|
|
14
|
-
- [
|
|
14
|
+
- [Handling Test Duplicates](#handling-test-duplicates)
|
|
15
15
|
- [Environment Detection](#environment-detection)
|
|
16
16
|
- [CI Integration](#ci-integration)
|
|
17
17
|
- [Configuration Options](#configuration-options)
|
|
@@ -19,7 +19,7 @@ A custom Vitest reporter that generates Flakiness Reports from your Vitest test
|
|
|
19
19
|
- [`endpoint?: string`](#endpoint-string)
|
|
20
20
|
- [`token?: string`](#token-string)
|
|
21
21
|
- [`outputFolder?: string`](#outputfolder-string)
|
|
22
|
-
- [`
|
|
22
|
+
- [`duplicates?: 'fail' | 'rename'`](#duplicates-fail--rename)
|
|
23
23
|
- [`disableUpload?: boolean`](#disableupload-boolean)
|
|
24
24
|
- [Environment Variables](#environment-variables)
|
|
25
25
|
- [Example Configuration](#example-configuration)
|
|
@@ -47,13 +47,18 @@ export default defineConfig({
|
|
|
47
47
|
test: {
|
|
48
48
|
includeTaskLocation: true,
|
|
49
49
|
reporters: [
|
|
50
|
-
|
|
50
|
+
'default',
|
|
51
|
+
['@flakiness/vitest', { flakinessProject: 'my-org/my-project' }]
|
|
51
52
|
],
|
|
52
53
|
},
|
|
53
54
|
});
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
>
|
|
57
|
+
> [!NOTE]
|
|
58
|
+
> The flakiness reporter should be added alongside your other reporters. Include `'default'` (or another built-in reporter) to retain the standard terminal output, since Vitest only uses the reporters listed in the `reporters` array.
|
|
59
|
+
|
|
60
|
+
> [!TIP]
|
|
61
|
+
> Setting `includeTaskLocation: true` is recommended to enable test locations in the report.
|
|
57
62
|
|
|
58
63
|
Run your tests. The report will be automatically generated in the `./flakiness-report` folder:
|
|
59
64
|
|
|
@@ -92,16 +97,30 @@ npx flakiness show ./flakiness-report
|
|
|
92
97
|
|
|
93
98
|
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
99
|
|
|
95
|
-
###
|
|
100
|
+
### Handling Test Duplicates
|
|
96
101
|
|
|
97
|
-
Vitest allows
|
|
102
|
+
Vitest allows creating tests with identical names:
|
|
98
103
|
|
|
99
104
|
```typescript
|
|
105
|
+
// Trivial duplicates
|
|
100
106
|
it('should work', () => { /* ... */ });
|
|
101
107
|
it('should work', () => { /* ... */ });
|
|
108
|
+
|
|
109
|
+
// More common way to end up with test duplicates:
|
|
110
|
+
it.for([
|
|
111
|
+
{ input: 1, expected: 2 },
|
|
112
|
+
{ input: 1, expected: 3 },
|
|
113
|
+
])('should handle $input', ({ input, expected }) => { /* ... */ });
|
|
102
114
|
```
|
|
103
115
|
|
|
104
|
-
Flakiness.io
|
|
116
|
+
Flakiness.io, however, **does not allow** test duplicates.
|
|
117
|
+
|
|
118
|
+
Flakiness.io considers two tests to be *duplicates* when they:
|
|
119
|
+
1. Share the same parent suite hierarchy
|
|
120
|
+
2. Have the same test name
|
|
121
|
+
3. And run in the same Vitest project
|
|
122
|
+
|
|
123
|
+
Flakiness.io relies on full test names to construct test history, so each test must have a unique full name. When Vitest reporter detects tests with identical full names, it issues warnings and handles them according to the [`duplicates`](#duplicates-fail--rename) option.
|
|
105
124
|
|
|
106
125
|
### Environment Detection
|
|
107
126
|
|
|
@@ -183,20 +202,23 @@ reporters: [
|
|
|
183
202
|
]
|
|
184
203
|
```
|
|
185
204
|
|
|
186
|
-
### `
|
|
205
|
+
### `duplicates?: 'fail' | 'rename'`
|
|
206
|
+
|
|
207
|
+
Controls how the reporter handles tests with duplicate full names. Defaults to `'fail'`.
|
|
187
208
|
|
|
188
|
-
|
|
209
|
+
> [!WARNING]
|
|
210
|
+
> The `'rename'` mode is **not recommended** for regular use. There is no guarantee that test histories will remain stable for duplicate tests, since the renaming is based on internal Vitest identifiers that may change between runs. This mode exists to help evaluate the reporter against large Vitest projects that have not yet resolved their duplicate test names.
|
|
189
211
|
|
|
190
|
-
- **`'
|
|
191
|
-
- **`'
|
|
192
|
-
- **`'never'`**: Never automatically opens the report
|
|
212
|
+
- **`'fail'`** (default): Duplicate tests are marked as failed with a descriptive error message and a `dupe` annotation. The first duplicate gets a single failed attempt explaining the problem; the remaining duplicates are stripped of their attempts (effectively hidden from the report). This is the recommended mode — it surfaces the problem so you can fix it by renaming your tests.
|
|
213
|
+
- **`'rename'`**: Duplicate tests are automatically renamed by appending a suffix (e.g., ` – dupe #2`, ` – dupe #3`) to their titles. Each renamed test also receives a `dupe` annotation on all its attempts. The first test with a given name keeps its original title; only the subsequent duplicates are renamed.
|
|
193
214
|
|
|
194
215
|
```typescript
|
|
195
216
|
reporters: [
|
|
196
|
-
['@flakiness/vitest', {
|
|
217
|
+
['@flakiness/vitest', { duplicates: 'rename' }]
|
|
197
218
|
]
|
|
198
219
|
```
|
|
199
220
|
|
|
221
|
+
|
|
200
222
|
### `disableUpload?: boolean`
|
|
201
223
|
|
|
202
224
|
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.
|
|
@@ -227,12 +249,13 @@ export default defineConfig({
|
|
|
227
249
|
test: {
|
|
228
250
|
includeTaskLocation: true,
|
|
229
251
|
reporters: [
|
|
252
|
+
'default',
|
|
230
253
|
['@flakiness/vitest', {
|
|
231
254
|
flakinessProject: 'my-org/my-project',
|
|
232
255
|
endpoint: process.env.FLAKINESS_ENDPOINT,
|
|
233
256
|
token: process.env.FLAKINESS_ACCESS_TOKEN,
|
|
234
257
|
outputFolder: './flakiness-report',
|
|
235
|
-
|
|
258
|
+
duplicates: 'fail',
|
|
236
259
|
disableUpload: false,
|
|
237
260
|
}]
|
|
238
261
|
],
|
package/lib/reporter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FlakinessReport as FK } from "@flakiness/flakiness-report";
|
|
2
|
-
import { CIUtils, CPUUtilization, GitWorktree, RAMUtilization, ReportUtils,
|
|
2
|
+
import { CIUtils, CPUUtilization, GitWorktree, RAMUtilization, ReportUtils, uploadReport, writeReport } from "@flakiness/sdk";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import crypto from "crypto";
|
|
5
5
|
import assert from "node:assert";
|
|
@@ -9,6 +9,7 @@ class FKVitestReporter {
|
|
|
9
9
|
this._options = _options;
|
|
10
10
|
}
|
|
11
11
|
_impl;
|
|
12
|
+
_vitest;
|
|
12
13
|
_logger = {
|
|
13
14
|
warn: (txt) => console.warn(chalk.yellow(txt)),
|
|
14
15
|
error: (txt) => console.error(chalk.red(txt)),
|
|
@@ -17,58 +18,47 @@ class FKVitestReporter {
|
|
|
17
18
|
setLoggerForTest(logger) {
|
|
18
19
|
this._logger = logger;
|
|
19
20
|
}
|
|
20
|
-
onUserConsoleLog(log) {
|
|
21
|
-
this._impl?.onUserConsoleLog(log);
|
|
21
|
+
async onUserConsoleLog(log) {
|
|
22
|
+
await this._impl?.onUserConsoleLog(log);
|
|
22
23
|
}
|
|
23
24
|
onInit(vitest) {
|
|
24
|
-
this.
|
|
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);
|
|
25
|
+
this._vitest = vitest;
|
|
32
26
|
}
|
|
33
27
|
onTestRunStart() {
|
|
34
|
-
this.
|
|
35
|
-
|
|
36
|
-
async onTestCaseResult(testCase) {
|
|
37
|
-
this._impl?.onTestCaseResult(testCase);
|
|
28
|
+
assert(this._vitest, "onInit must be called before onTestRunStart");
|
|
29
|
+
this._impl = ReporterImpl.create(this._vitest.config.root, this._options, this._logger, this._vitest.config.config);
|
|
38
30
|
}
|
|
39
31
|
async onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
40
|
-
this._impl?.onTestRunEnd(testModules, unhandledErrors, reason);
|
|
32
|
+
await this._impl?.onTestRunEnd(testModules, unhandledErrors, reason);
|
|
33
|
+
this._impl = void 0;
|
|
41
34
|
}
|
|
42
35
|
}
|
|
36
|
+
const gTestToTestCaseId = /* @__PURE__ */ new WeakMap();
|
|
43
37
|
class ReporterImpl {
|
|
44
|
-
constructor(_worktree, _commitId, _options, _logger) {
|
|
38
|
+
constructor(_worktree, _commitId, _options, _logger, _configPath) {
|
|
45
39
|
this._worktree = _worktree;
|
|
46
40
|
this._commitId = _commitId;
|
|
47
41
|
this._options = _options;
|
|
48
42
|
this._logger = _logger;
|
|
43
|
+
this._configPath = _configPath;
|
|
49
44
|
this._sampleSystem = this._sampleSystem.bind(this);
|
|
45
|
+
this._sampleSystem();
|
|
50
46
|
}
|
|
51
47
|
_telemetryTimer;
|
|
52
48
|
_cpuUtilization = new CPUUtilization({ precision: 10 });
|
|
53
49
|
_ramUtilization = new RAMUtilization({ precision: 10 });
|
|
54
50
|
_startTimestamp = Date.now();
|
|
55
|
-
_testCaseIdToTest = /* @__PURE__ */ new Map();
|
|
56
|
-
_testToTestCaseId = /* @__PURE__ */ new Map();
|
|
57
51
|
_stdio = /* @__PURE__ */ new Map();
|
|
58
|
-
_annotations = /* @__PURE__ */ new Map();
|
|
59
52
|
// In Vitest, all projects MUST HAVE UNIQUE NAMES.
|
|
60
53
|
// So we create environments per project name.
|
|
61
54
|
_environments = [];
|
|
62
|
-
|
|
63
|
-
_fileSuites = /* @__PURE__ */ new Map();
|
|
64
|
-
_configPath;
|
|
65
|
-
static create(rootPath, options, logger) {
|
|
55
|
+
static create(rootPath, options, logger, config) {
|
|
66
56
|
let commitId;
|
|
67
57
|
let worktree;
|
|
68
58
|
try {
|
|
69
59
|
worktree = GitWorktree.create(rootPath);
|
|
70
60
|
commitId = worktree.headCommitId();
|
|
71
|
-
return new ReporterImpl(worktree, commitId, options, logger);
|
|
61
|
+
return new ReporterImpl(worktree, commitId, options, logger, config);
|
|
72
62
|
} catch (e) {
|
|
73
63
|
logger.warn(`[flakiness.io] Failed to fetch commit info - is this a git repo?`);
|
|
74
64
|
logger.error(`[flakiness.io] Report is NOT generated.`);
|
|
@@ -80,9 +70,6 @@ class ReporterImpl {
|
|
|
80
70
|
this._ramUtilization.sample();
|
|
81
71
|
this._telemetryTimer = setTimeout(this._sampleSystem, 1e3);
|
|
82
72
|
}
|
|
83
|
-
onInit(vitest) {
|
|
84
|
-
this._configPath = vitest.config.config;
|
|
85
|
-
}
|
|
86
73
|
onUserConsoleLog(log) {
|
|
87
74
|
if (!log.taskId)
|
|
88
75
|
return;
|
|
@@ -93,70 +80,6 @@ class ReporterImpl {
|
|
|
93
80
|
}
|
|
94
81
|
entries.push(log);
|
|
95
82
|
}
|
|
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
83
|
_ensureEnvironmentIdx(testCase) {
|
|
161
84
|
const projectName = testCase.project.name || "vitest";
|
|
162
85
|
let idx = this._environments.findIndex((env) => env.name === projectName);
|
|
@@ -176,13 +99,42 @@ class ReporterImpl {
|
|
|
176
99
|
column: frame.column
|
|
177
100
|
} : void 0;
|
|
178
101
|
}
|
|
179
|
-
|
|
102
|
+
_collectSuite(fkParent, suite) {
|
|
103
|
+
const fkSuite = {
|
|
104
|
+
type: "suite",
|
|
105
|
+
title: suite.name,
|
|
106
|
+
location: suite.location ? {
|
|
107
|
+
file: this._worktree.gitPath(suite.module.moduleId),
|
|
108
|
+
column: suite.location.column,
|
|
109
|
+
line: suite.location.line
|
|
110
|
+
} : void 0
|
|
111
|
+
};
|
|
112
|
+
fkParent.suites ??= [];
|
|
113
|
+
fkParent.suites.push(fkSuite);
|
|
114
|
+
for (const s of suite.children.suites())
|
|
115
|
+
this._collectSuite(fkSuite, s);
|
|
116
|
+
for (const t of suite.children.tests())
|
|
117
|
+
this._collectTest(fkSuite, t);
|
|
118
|
+
}
|
|
119
|
+
async _collectTest(fkParent, testCase) {
|
|
180
120
|
const environmentIdx = this._ensureEnvironmentIdx(testCase);
|
|
181
|
-
const fkTest =
|
|
121
|
+
const fkTest = {
|
|
122
|
+
attempts: [],
|
|
123
|
+
title: testCase.name,
|
|
124
|
+
location: testCase.location ? {
|
|
125
|
+
file: this._worktree.gitPath(testCase.module.moduleId),
|
|
126
|
+
column: testCase.location.column,
|
|
127
|
+
line: testCase.location.line
|
|
128
|
+
} : void 0
|
|
129
|
+
};
|
|
130
|
+
fkParent.tests ??= [];
|
|
131
|
+
fkParent.tests.push(fkTest);
|
|
132
|
+
gTestToTestCaseId.set(fkTest, testCase.id);
|
|
182
133
|
const result = testCase.result();
|
|
183
|
-
if (result.state === "skipped") {
|
|
134
|
+
if (result.state === "skipped" || result.state === "pending") {
|
|
184
135
|
fkTest.attempts.push({
|
|
185
136
|
environmentIdx,
|
|
137
|
+
annotations: testCase.options.mode === "skip" ? [{ type: "skip" }] : testCase.options.mode === "todo" ? [{ type: "todo" }] : void 0,
|
|
186
138
|
startTimestamp: Date.now(),
|
|
187
139
|
duration: 0,
|
|
188
140
|
status: "skipped",
|
|
@@ -190,11 +142,11 @@ class ReporterImpl {
|
|
|
190
142
|
});
|
|
191
143
|
return;
|
|
192
144
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
;
|
|
145
|
+
const startTime = testCase.diagnostic()?.startTime ?? this._startTimestamp;
|
|
146
|
+
const duration = testCase.diagnostic()?.duration ?? testCase.module.diagnostic()?.duration ?? 0;
|
|
147
|
+
const retryCount = testCase.diagnostic()?.retryCount ?? 0;
|
|
196
148
|
const stdio = [];
|
|
197
|
-
let ts =
|
|
149
|
+
let ts = startTime;
|
|
198
150
|
for (const entry of this._stdio.get(testCase.id) ?? []) {
|
|
199
151
|
stdio.push({
|
|
200
152
|
text: entry.content,
|
|
@@ -210,7 +162,7 @@ class ReporterImpl {
|
|
|
210
162
|
stack: error.stack,
|
|
211
163
|
location: this._errorLocation(error.stacks, testFile)
|
|
212
164
|
}));
|
|
213
|
-
const annotations =
|
|
165
|
+
const annotations = testCase.annotations().map((annotation) => ({
|
|
214
166
|
type: annotation.type,
|
|
215
167
|
description: annotation.message,
|
|
216
168
|
location: annotation.location ? {
|
|
@@ -219,12 +171,14 @@ class ReporterImpl {
|
|
|
219
171
|
column: annotation.location.column
|
|
220
172
|
} : void 0
|
|
221
173
|
}));
|
|
174
|
+
if (testCase.options.fails)
|
|
175
|
+
annotations.push({ type: "fail" });
|
|
222
176
|
const expectedStatus = testCase.options.fails ? "failed" : "passed";
|
|
223
177
|
const oppositeStatus = expectedStatus === "failed" ? "passed" : "failed";
|
|
224
|
-
for (let i = 0; i <
|
|
178
|
+
for (let i = 0; i < retryCount; ++i) {
|
|
225
179
|
fkTest.attempts.push({
|
|
226
180
|
environmentIdx,
|
|
227
|
-
startTimestamp:
|
|
181
|
+
startTimestamp: startTime,
|
|
228
182
|
duration: 0,
|
|
229
183
|
// retries have an opposite status from expected status to
|
|
230
184
|
// trigger retry.
|
|
@@ -232,15 +186,17 @@ class ReporterImpl {
|
|
|
232
186
|
expectedStatus,
|
|
233
187
|
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
234
188
|
// However, vitest doesn't let us do so easily.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
189
|
+
// We slice these arrays just to be safe: if someone downstream from us
|
|
190
|
+
// decides to push a new annotation, then they can do it per-attempt safely.
|
|
191
|
+
stdio: stdio.slice(),
|
|
192
|
+
errors: errors.slice(),
|
|
193
|
+
annotations: annotations.slice()
|
|
238
194
|
});
|
|
239
195
|
}
|
|
240
196
|
fkTest.attempts.push({
|
|
241
197
|
environmentIdx,
|
|
242
|
-
startTimestamp:
|
|
243
|
-
duration
|
|
198
|
+
startTimestamp: startTime,
|
|
199
|
+
duration,
|
|
244
200
|
status: result.state === "failed" ? oppositeStatus : expectedStatus,
|
|
245
201
|
expectedStatus,
|
|
246
202
|
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
@@ -250,38 +206,17 @@ class ReporterImpl {
|
|
|
250
206
|
annotations
|
|
251
207
|
});
|
|
252
208
|
}
|
|
253
|
-
|
|
254
|
-
const TITLE_SEPARATOR = crypto.randomBytes(128).toString("base64");
|
|
255
|
-
const testIdToTests = /* @__PURE__ */ new Map();
|
|
256
|
-
const testFullNames = /* @__PURE__ */ new Map();
|
|
257
|
-
function visitSuite(suite, parentTitles = []) {
|
|
258
|
-
parentTitles.push(suite.title);
|
|
259
|
-
for (const childSuite of suite.suites ?? [])
|
|
260
|
-
visitSuite(childSuite, parentTitles);
|
|
261
|
-
for (const test of suite.tests ?? []) {
|
|
262
|
-
const envs = Array.from(new Set(test.attempts.map((attempt) => attempt.environmentIdx ?? 0))).sort((a, b) => a - b);
|
|
263
|
-
const testId = [`[${envs.join(", ")}]`, ...parentTitles, test.title].join(TITLE_SEPARATOR);
|
|
264
|
-
testFullNames.set(test, [...parentTitles, test.title].join(" > "));
|
|
265
|
-
let tests = testIdToTests.get(testId);
|
|
266
|
-
if (!tests) {
|
|
267
|
-
tests = [];
|
|
268
|
-
testIdToTests.set(testId, tests);
|
|
269
|
-
}
|
|
270
|
-
tests.push(test);
|
|
271
|
-
}
|
|
272
|
-
parentTitles.pop();
|
|
273
|
-
}
|
|
274
|
-
for (const fileSuite of this._fileSuites.values())
|
|
275
|
-
visitSuite(fileSuite);
|
|
276
|
-
if (Array.from(testIdToTests.values()).every((tests) => tests.length <= 1))
|
|
277
|
-
return;
|
|
209
|
+
_warnDuplicates(duplicates) {
|
|
278
210
|
const warnMessages = [];
|
|
279
|
-
for (const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
211
|
+
for (const duplicate of duplicates)
|
|
212
|
+
warnMessages.push(`${duplicate.tests.length} tests: ${duplicate.fullName}`);
|
|
213
|
+
this._logger.warn(`[flakiness.io] \u26A0 Detected test with duplicate names!`);
|
|
214
|
+
for (const warnMessage of warnMessages)
|
|
215
|
+
this._logger.warn(`[flakiness.io] - ${warnMessage}`);
|
|
216
|
+
this._logger.warn(`[flakiness.io] Please rename tests so that they all have unique full names.`);
|
|
217
|
+
}
|
|
218
|
+
_failDuplicates(duplicates) {
|
|
219
|
+
for (const { fullName, tests } of duplicates) {
|
|
285
220
|
const test = tests[0];
|
|
286
221
|
const envs = new Set(test.attempts.map((a) => a.environmentIdx ?? 0));
|
|
287
222
|
test.attempts = Array.from(envs).map((envIdx) => ({
|
|
@@ -292,15 +227,15 @@ class ReporterImpl {
|
|
|
292
227
|
status: "failed",
|
|
293
228
|
errors: [{
|
|
294
229
|
message: [
|
|
295
|
-
`Flakiness.io detected ${tests.length} tests with identical full name "${
|
|
230
|
+
`Flakiness.io detected ${tests.length} tests with identical full name "${fullName}"`,
|
|
296
231
|
`Please rename tests to ensure they all have unique full names.`
|
|
297
232
|
].join("\n")
|
|
298
233
|
}],
|
|
299
234
|
annotations: [{
|
|
300
|
-
type: "
|
|
235
|
+
type: "dupe",
|
|
301
236
|
description: [
|
|
302
237
|
`Flakiness.io failed to process this test because there are ${tests.length} tests with`,
|
|
303
|
-
`identical full name: "${
|
|
238
|
+
`identical full name: "${fullName}".`,
|
|
304
239
|
`Please make sure that all your tests have unique full names.`
|
|
305
240
|
].join("\n")
|
|
306
241
|
}]
|
|
@@ -308,18 +243,88 @@ class ReporterImpl {
|
|
|
308
243
|
for (let i = 1; i < tests.length; ++i)
|
|
309
244
|
tests[i].attempts = [];
|
|
310
245
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
246
|
+
}
|
|
247
|
+
_renameDuplicates(duplicates, testIdToTests) {
|
|
248
|
+
for (const { tests, testId } of duplicates) {
|
|
249
|
+
tests.sort((test1, test2) => {
|
|
250
|
+
const id1 = gTestToTestCaseId.get(test1) ?? "";
|
|
251
|
+
const id2 = gTestToTestCaseId.get(test2) ?? "";
|
|
252
|
+
return id1 < id2 ? -1 : id1 > id2 ? 1 : 0;
|
|
253
|
+
});
|
|
254
|
+
let dupeIndex = 2;
|
|
255
|
+
for (let i = 1; i < tests.length; ++i) {
|
|
256
|
+
while (testIdToTests.has(testId + dupeSuffix(dupeIndex)))
|
|
257
|
+
++dupeIndex;
|
|
258
|
+
tests[i].title += dupeSuffix(dupeIndex);
|
|
259
|
+
tests[i].attempts.forEach((attempt) => {
|
|
260
|
+
attempt.annotations ??= [];
|
|
261
|
+
attempt.annotations.push({ type: "dupe" });
|
|
262
|
+
});
|
|
263
|
+
testIdToTests.set(testId + dupeSuffix(dupeIndex), [tests[i]]);
|
|
264
|
+
}
|
|
316
265
|
}
|
|
317
266
|
}
|
|
267
|
+
_detectDuplicates(fileSuites) {
|
|
268
|
+
const TITLE_SEPARATOR = crypto.randomBytes(128).toString("base64");
|
|
269
|
+
const testIdToTests = /* @__PURE__ */ new Map();
|
|
270
|
+
const testFullNames = /* @__PURE__ */ new Map();
|
|
271
|
+
function visitSuite(suite, parentTitles = []) {
|
|
272
|
+
parentTitles.push(suite.title);
|
|
273
|
+
for (const childSuite of suite.suites ?? [])
|
|
274
|
+
visitSuite(childSuite, parentTitles);
|
|
275
|
+
for (const test of suite.tests ?? []) {
|
|
276
|
+
const envs = Array.from(new Set(test.attempts.map((attempt) => attempt.environmentIdx ?? 0))).sort((a, b) => a - b);
|
|
277
|
+
const testId = [`[${envs.join(", ")}]`, ...parentTitles, test.title].join(TITLE_SEPARATOR);
|
|
278
|
+
testFullNames.set(test, [...parentTitles, test.title].join(" > "));
|
|
279
|
+
let tests = testIdToTests.get(testId);
|
|
280
|
+
if (!tests) {
|
|
281
|
+
tests = [];
|
|
282
|
+
testIdToTests.set(testId, tests);
|
|
283
|
+
}
|
|
284
|
+
tests.push(test);
|
|
285
|
+
}
|
|
286
|
+
parentTitles.pop();
|
|
287
|
+
}
|
|
288
|
+
for (const fileSuite of fileSuites)
|
|
289
|
+
visitSuite(fileSuite);
|
|
290
|
+
const duplicates = [];
|
|
291
|
+
for (const [testId, tests] of testIdToTests) {
|
|
292
|
+
if (tests.length <= 1)
|
|
293
|
+
continue;
|
|
294
|
+
const fullName = testFullNames.get(tests[0]);
|
|
295
|
+
duplicates.push({ fullName, testId, tests });
|
|
296
|
+
}
|
|
297
|
+
return { duplicates, testIdToTests };
|
|
298
|
+
}
|
|
318
299
|
async onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
300
|
+
const fileSuites = testModules.map((file) => {
|
|
301
|
+
const fkFileSuite = {
|
|
302
|
+
type: "file",
|
|
303
|
+
title: file.relativeModuleId,
|
|
304
|
+
location: {
|
|
305
|
+
file: this._worktree.gitPath(file.moduleId),
|
|
306
|
+
column: 0,
|
|
307
|
+
line: 0
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
for (const suite of file.children.suites())
|
|
311
|
+
this._collectSuite(fkFileSuite, suite);
|
|
312
|
+
for (const test of file.children.tests())
|
|
313
|
+
this._collectTest(fkFileSuite, test);
|
|
314
|
+
return fkFileSuite;
|
|
315
|
+
});
|
|
319
316
|
clearTimeout(this._telemetryTimer);
|
|
320
317
|
this._cpuUtilization.sample();
|
|
321
318
|
this._ramUtilization.sample();
|
|
322
|
-
this.
|
|
319
|
+
const { duplicates, testIdToTests } = this._detectDuplicates(fileSuites);
|
|
320
|
+
if (duplicates.length) {
|
|
321
|
+
this._warnDuplicates(duplicates);
|
|
322
|
+
if (this._options.duplicates === "rename") {
|
|
323
|
+
this._renameDuplicates(duplicates, testIdToTests);
|
|
324
|
+
} else {
|
|
325
|
+
this._failDuplicates(duplicates);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
323
328
|
const duration = Date.now() - this._startTimestamp;
|
|
324
329
|
const report = ReportUtils.normalizeReport({
|
|
325
330
|
url: CIUtils.runUrl(),
|
|
@@ -330,7 +335,7 @@ class ReporterImpl {
|
|
|
330
335
|
environments: this._environments,
|
|
331
336
|
startTimestamp: this._startTimestamp,
|
|
332
337
|
duration,
|
|
333
|
-
suites:
|
|
338
|
+
suites: fileSuites,
|
|
334
339
|
unattributedErrors: unhandledErrors.map((error) => ({
|
|
335
340
|
message: error.message,
|
|
336
341
|
stack: error.stack,
|
|
@@ -353,19 +358,13 @@ class ReporterImpl {
|
|
|
353
358
|
logger: this._logger
|
|
354
359
|
});
|
|
355
360
|
}
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
await showReport(outputFolder);
|
|
360
|
-
} else {
|
|
361
|
-
const defaultOutputFolder = path.join(process.cwd(), "flakiness-report");
|
|
362
|
-
const folder = defaultOutputFolder === outputFolder ? "" : path.relative(process.cwd(), outputFolder);
|
|
363
|
-
this._logger.log(`
|
|
361
|
+
const defaultOutputFolder = path.join(process.cwd(), "flakiness-report");
|
|
362
|
+
const folder = defaultOutputFolder === outputFolder ? "" : path.relative(process.cwd(), outputFolder);
|
|
363
|
+
this._logger.log(`
|
|
364
364
|
To open last Flakiness report, run:
|
|
365
365
|
|
|
366
366
|
${chalk.cyan(`npx flakiness show ${folder}`)}
|
|
367
|
-
|
|
368
|
-
}
|
|
367
|
+
`);
|
|
369
368
|
}
|
|
370
369
|
}
|
|
371
370
|
function dupeSuffix(dupeIndex) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flakiness/vitest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "lib/reporter.js",
|
|
6
6
|
"keywords": [],
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"devDependencies": {
|
|
15
|
+
"@flakiness/vitest": "0.1.0-alpha.2",
|
|
15
16
|
"@types/node": "^25.3.3",
|
|
16
17
|
"@vitest/utils": "^4.0.18",
|
|
17
18
|
"esbuild": "^0.27.3",
|
package/types/src/reporter.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SerializedError,
|
|
1
|
+
import type { SerializedError, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
|
|
2
2
|
import type { Reporter } from 'vitest/reporters';
|
|
3
3
|
interface UserConsoleLog {
|
|
4
4
|
content: string;
|
|
@@ -9,36 +9,13 @@ interface UserConsoleLog {
|
|
|
9
9
|
time: number;
|
|
10
10
|
size: number;
|
|
11
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
12
|
export type FKVitestReporterOptions = {
|
|
36
13
|
disableUpload?: boolean;
|
|
37
14
|
flakinessProject?: string;
|
|
38
15
|
endpoint?: string;
|
|
39
16
|
token?: string;
|
|
40
17
|
outputFolder?: string;
|
|
41
|
-
|
|
18
|
+
duplicates?: 'fail' | 'rename';
|
|
42
19
|
};
|
|
43
20
|
export interface FKVitestLogger {
|
|
44
21
|
log(txt: string): void;
|
|
@@ -48,17 +25,13 @@ export interface FKVitestLogger {
|
|
|
48
25
|
export default class FKVitestReporter implements Reporter {
|
|
49
26
|
private _options;
|
|
50
27
|
private _impl?;
|
|
28
|
+
private _vitest?;
|
|
51
29
|
private _logger;
|
|
52
30
|
constructor(_options: FKVitestReporterOptions);
|
|
53
31
|
setLoggerForTest(logger: FKVitestLogger): void;
|
|
54
|
-
onUserConsoleLog(log: UserConsoleLog): void
|
|
32
|
+
onUserConsoleLog(log: UserConsoleLog): Promise<void>;
|
|
55
33
|
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
34
|
onTestRunStart(): void;
|
|
61
|
-
onTestCaseResult(testCase: TestCase): Promise<void>;
|
|
62
35
|
onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason): Promise<void>;
|
|
63
36
|
}
|
|
64
37
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../../src/reporter.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,eAAe,
|
|
1
|
+
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../../src/reporter.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,eAAe,EAAY,UAAU,EAAE,gBAAgB,EAAa,MAAM,EAAE,MAAM,aAAa,CAAC;AAC9G,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAIjD,UAAU,cAAc;IACtB,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;CACd;AAED,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,UAAU,CAAC,EAAE,MAAM,GAAC,QAAQ,CAAC;CAC9B,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;IAS3C,OAAO,CAAC,QAAQ;IAR5B,OAAO,CAAC,KAAK,CAAC,CAAe;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,OAAO,CAId;gBAEmB,QAAQ,EAAE,uBAAuB;IAGrD,gBAAgB,CAAC,MAAM,EAAE,cAAc;IAIjC,gBAAgB,CAAC,GAAG,EAAE,cAAc;IAI1C,MAAM,CAAC,MAAM,EAAE,MAAM;IAIrB,cAAc;IAOR,YAAY,CAAC,WAAW,EAAE,aAAa,CAAC,UAAU,CAAC,EAAE,eAAe,EAAE,aAAa,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,gBAAgB;CAIrI"}
|