@flakiness/vitest 0.1.0-alpha.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -6
- package/lib/reporter.js +152 -147
- 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,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,
|
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"}
|