@flakiness/vitest 0.1.0-alpha.1 → 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.
- package/README.md +45 -6
- package/lib/reporter.js +155 -149
- package/package.json +2 -1
- package/types/src/reporter.d.ts +4 -29
- 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,6 +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
|
+
- [`duplicates?: 'fail' | 'rename'`](#duplicates-fail--rename)
|
|
22
23
|
- [`open?: 'always' | 'never' | 'on-failure'`](#open-always--never--on-failure)
|
|
23
24
|
- [`disableUpload?: boolean`](#disableupload-boolean)
|
|
24
25
|
- [Environment Variables](#environment-variables)
|
|
@@ -47,13 +48,18 @@ export default defineConfig({
|
|
|
47
48
|
test: {
|
|
48
49
|
includeTaskLocation: true,
|
|
49
50
|
reporters: [
|
|
50
|
-
|
|
51
|
+
'default',
|
|
52
|
+
['@flakiness/vitest', { flakinessProject: 'my-org/my-project' }]
|
|
51
53
|
],
|
|
52
54
|
},
|
|
53
55
|
});
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
>
|
|
58
|
+
> [!NOTE]
|
|
59
|
+
> 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.
|
|
60
|
+
|
|
61
|
+
> [!TIP]
|
|
62
|
+
> Setting `includeTaskLocation: true` is recommended to enable test locations in the report.
|
|
57
63
|
|
|
58
64
|
Run your tests. The report will be automatically generated in the `./flakiness-report` folder:
|
|
59
65
|
|
|
@@ -92,16 +98,30 @@ npx flakiness show ./flakiness-report
|
|
|
92
98
|
|
|
93
99
|
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
100
|
|
|
95
|
-
###
|
|
101
|
+
### Handling Test Duplicates
|
|
96
102
|
|
|
97
|
-
Vitest allows
|
|
103
|
+
Vitest allows creating tests with identical names:
|
|
98
104
|
|
|
99
105
|
```typescript
|
|
106
|
+
// Trivial duplicates
|
|
100
107
|
it('should work', () => { /* ... */ });
|
|
101
108
|
it('should work', () => { /* ... */ });
|
|
109
|
+
|
|
110
|
+
// More common way to end up with test duplicates:
|
|
111
|
+
it.for([
|
|
112
|
+
{ input: 1, expected: 2 },
|
|
113
|
+
{ input: 1, expected: 3 },
|
|
114
|
+
])('should handle $input', ({ input, expected }) => { /* ... */ });
|
|
102
115
|
```
|
|
103
116
|
|
|
104
|
-
Flakiness.io
|
|
117
|
+
Flakiness.io, however, **does not allow** test duplicates.
|
|
118
|
+
|
|
119
|
+
Flakiness.io considers two tests to be *duplicates* when they:
|
|
120
|
+
1. Share the same parent suite hierarchy
|
|
121
|
+
2. Have the same test name
|
|
122
|
+
3. And run in the same Vitest project
|
|
123
|
+
|
|
124
|
+
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
125
|
|
|
106
126
|
### Environment Detection
|
|
107
127
|
|
|
@@ -183,6 +203,23 @@ reporters: [
|
|
|
183
203
|
]
|
|
184
204
|
```
|
|
185
205
|
|
|
206
|
+
### `duplicates?: 'fail' | 'rename'`
|
|
207
|
+
|
|
208
|
+
Controls how the reporter handles tests with duplicate full names. Defaults to `'fail'`.
|
|
209
|
+
|
|
210
|
+
> [!WARNING]
|
|
211
|
+
> 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.
|
|
212
|
+
|
|
213
|
+
- **`'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.
|
|
214
|
+
- **`'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.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
reporters: [
|
|
218
|
+
['@flakiness/vitest', { duplicates: 'rename' }]
|
|
219
|
+
]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
|
|
186
223
|
### `open?: 'always' | 'never' | 'on-failure'`
|
|
187
224
|
|
|
188
225
|
Controls when the report viewer should automatically open in your browser after test completion.
|
|
@@ -227,11 +264,13 @@ export default defineConfig({
|
|
|
227
264
|
test: {
|
|
228
265
|
includeTaskLocation: true,
|
|
229
266
|
reporters: [
|
|
267
|
+
'default',
|
|
230
268
|
['@flakiness/vitest', {
|
|
231
269
|
flakinessProject: 'my-org/my-project',
|
|
232
270
|
endpoint: process.env.FLAKINESS_ENDPOINT,
|
|
233
271
|
token: process.env.FLAKINESS_ACCESS_TOKEN,
|
|
234
272
|
outputFolder: './flakiness-report',
|
|
273
|
+
duplicates: 'fail',
|
|
235
274
|
open: 'on-failure',
|
|
236
275
|
disableUpload: false,
|
|
237
276
|
}]
|
package/lib/reporter.js
CHANGED
|
@@ -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,76 +80,13 @@ 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";
|
|
85
|
+
let idx = this._environments.findIndex((env) => env.name === projectName);
|
|
162
86
|
if (idx === -1) {
|
|
163
87
|
idx = this._environments.length;
|
|
164
88
|
this._environments.push(ReportUtils.createEnvironment({
|
|
165
|
-
name:
|
|
89
|
+
name: projectName
|
|
166
90
|
}));
|
|
167
91
|
}
|
|
168
92
|
return idx;
|
|
@@ -175,13 +99,42 @@ class ReporterImpl {
|
|
|
175
99
|
column: frame.column
|
|
176
100
|
} : void 0;
|
|
177
101
|
}
|
|
178
|
-
|
|
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) {
|
|
179
120
|
const environmentIdx = this._ensureEnvironmentIdx(testCase);
|
|
180
|
-
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);
|
|
181
133
|
const result = testCase.result();
|
|
182
|
-
if (result.state === "skipped") {
|
|
134
|
+
if (result.state === "skipped" || result.state === "pending") {
|
|
183
135
|
fkTest.attempts.push({
|
|
184
136
|
environmentIdx,
|
|
137
|
+
annotations: testCase.options.mode === "skip" ? [{ type: "skip" }] : testCase.options.mode === "todo" ? [{ type: "todo" }] : void 0,
|
|
185
138
|
startTimestamp: Date.now(),
|
|
186
139
|
duration: 0,
|
|
187
140
|
status: "skipped",
|
|
@@ -189,11 +142,11 @@ class ReporterImpl {
|
|
|
189
142
|
});
|
|
190
143
|
return;
|
|
191
144
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
;
|
|
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;
|
|
195
148
|
const stdio = [];
|
|
196
|
-
let ts =
|
|
149
|
+
let ts = startTime;
|
|
197
150
|
for (const entry of this._stdio.get(testCase.id) ?? []) {
|
|
198
151
|
stdio.push({
|
|
199
152
|
text: entry.content,
|
|
@@ -209,7 +162,7 @@ class ReporterImpl {
|
|
|
209
162
|
stack: error.stack,
|
|
210
163
|
location: this._errorLocation(error.stacks, testFile)
|
|
211
164
|
}));
|
|
212
|
-
const annotations =
|
|
165
|
+
const annotations = testCase.annotations().map((annotation) => ({
|
|
213
166
|
type: annotation.type,
|
|
214
167
|
description: annotation.message,
|
|
215
168
|
location: annotation.location ? {
|
|
@@ -218,12 +171,14 @@ class ReporterImpl {
|
|
|
218
171
|
column: annotation.location.column
|
|
219
172
|
} : void 0
|
|
220
173
|
}));
|
|
174
|
+
if (testCase.options.fails)
|
|
175
|
+
annotations.push({ type: "fail" });
|
|
221
176
|
const expectedStatus = testCase.options.fails ? "failed" : "passed";
|
|
222
177
|
const oppositeStatus = expectedStatus === "failed" ? "passed" : "failed";
|
|
223
|
-
for (let i = 0; i <
|
|
178
|
+
for (let i = 0; i < retryCount; ++i) {
|
|
224
179
|
fkTest.attempts.push({
|
|
225
180
|
environmentIdx,
|
|
226
|
-
startTimestamp:
|
|
181
|
+
startTimestamp: startTime,
|
|
227
182
|
duration: 0,
|
|
228
183
|
// retries have an opposite status from expected status to
|
|
229
184
|
// trigger retry.
|
|
@@ -231,15 +186,17 @@ class ReporterImpl {
|
|
|
231
186
|
expectedStatus,
|
|
232
187
|
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
233
188
|
// However, vitest doesn't let us do so easily.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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()
|
|
237
194
|
});
|
|
238
195
|
}
|
|
239
196
|
fkTest.attempts.push({
|
|
240
197
|
environmentIdx,
|
|
241
|
-
startTimestamp:
|
|
242
|
-
duration
|
|
198
|
+
startTimestamp: startTime,
|
|
199
|
+
duration,
|
|
243
200
|
status: result.state === "failed" ? oppositeStatus : expectedStatus,
|
|
244
201
|
expectedStatus,
|
|
245
202
|
// TODO: ideally, we can differentiate STDIO between attempts.
|
|
@@ -249,38 +206,17 @@ class ReporterImpl {
|
|
|
249
206
|
annotations
|
|
250
207
|
});
|
|
251
208
|
}
|
|
252
|
-
|
|
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;
|
|
209
|
+
_warnDuplicates(duplicates) {
|
|
277
210
|
const warnMessages = [];
|
|
278
|
-
for (const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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) {
|
|
284
220
|
const test = tests[0];
|
|
285
221
|
const envs = new Set(test.attempts.map((a) => a.environmentIdx ?? 0));
|
|
286
222
|
test.attempts = Array.from(envs).map((envIdx) => ({
|
|
@@ -291,15 +227,15 @@ class ReporterImpl {
|
|
|
291
227
|
status: "failed",
|
|
292
228
|
errors: [{
|
|
293
229
|
message: [
|
|
294
|
-
`Flakiness.io detected ${tests.length} tests with identical full name "${
|
|
230
|
+
`Flakiness.io detected ${tests.length} tests with identical full name "${fullName}"`,
|
|
295
231
|
`Please rename tests to ensure they all have unique full names.`
|
|
296
232
|
].join("\n")
|
|
297
233
|
}],
|
|
298
234
|
annotations: [{
|
|
299
|
-
type: "
|
|
235
|
+
type: "dupe",
|
|
300
236
|
description: [
|
|
301
237
|
`Flakiness.io failed to process this test because there are ${tests.length} tests with`,
|
|
302
|
-
`identical full name: "${
|
|
238
|
+
`identical full name: "${fullName}".`,
|
|
303
239
|
`Please make sure that all your tests have unique full names.`
|
|
304
240
|
].join("\n")
|
|
305
241
|
}]
|
|
@@ -307,18 +243,88 @@ class ReporterImpl {
|
|
|
307
243
|
for (let i = 1; i < tests.length; ++i)
|
|
308
244
|
tests[i].attempts = [];
|
|
309
245
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
}
|
|
315
265
|
}
|
|
316
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
|
+
}
|
|
317
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
|
+
});
|
|
318
316
|
clearTimeout(this._telemetryTimer);
|
|
319
317
|
this._cpuUtilization.sample();
|
|
320
318
|
this._ramUtilization.sample();
|
|
321
|
-
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
|
+
}
|
|
322
328
|
const duration = Date.now() - this._startTimestamp;
|
|
323
329
|
const report = ReportUtils.normalizeReport({
|
|
324
330
|
url: CIUtils.runUrl(),
|
|
@@ -329,7 +335,7 @@ class ReporterImpl {
|
|
|
329
335
|
environments: this._environments,
|
|
330
336
|
startTimestamp: this._startTimestamp,
|
|
331
337
|
duration,
|
|
332
|
-
suites:
|
|
338
|
+
suites: fileSuites,
|
|
333
339
|
unattributedErrors: unhandledErrors.map((error) => ({
|
|
334
340
|
message: error.message,
|
|
335
341
|
stack: error.stack,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flakiness/vitest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.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,28 +9,6 @@ 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
12
|
export type OpenMode = 'always' | 'never' | 'on-failure';
|
|
35
13
|
export type FKVitestReporterOptions = {
|
|
36
14
|
disableUpload?: boolean;
|
|
@@ -38,6 +16,7 @@ export type FKVitestReporterOptions = {
|
|
|
38
16
|
endpoint?: string;
|
|
39
17
|
token?: string;
|
|
40
18
|
outputFolder?: string;
|
|
19
|
+
duplicates?: 'fail' | 'rename';
|
|
41
20
|
open?: OpenMode;
|
|
42
21
|
};
|
|
43
22
|
export interface FKVitestLogger {
|
|
@@ -48,17 +27,13 @@ export interface FKVitestLogger {
|
|
|
48
27
|
export default class FKVitestReporter implements Reporter {
|
|
49
28
|
private _options;
|
|
50
29
|
private _impl?;
|
|
30
|
+
private _vitest?;
|
|
51
31
|
private _logger;
|
|
52
32
|
constructor(_options: FKVitestReporterOptions);
|
|
53
33
|
setLoggerForTest(logger: FKVitestLogger): void;
|
|
54
|
-
onUserConsoleLog(log: UserConsoleLog): void
|
|
34
|
+
onUserConsoleLog(log: UserConsoleLog): Promise<void>;
|
|
55
35
|
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
36
|
onTestRunStart(): void;
|
|
61
|
-
onTestCaseResult(testCase: TestCase): Promise<void>;
|
|
62
37
|
onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason): Promise<void>;
|
|
63
38
|
}
|
|
64
39
|
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,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,UAAU,CAAC,EAAE,MAAM,GAAC,QAAQ,CAAC;IAC7B,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;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"}
|