@flakiness/cucumberjs 1.0.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/.github/workflows/flakiness-upload-fork-prs.yml +30 -0
- package/.github/workflows/publish-npm.yml +41 -0
- package/.github/workflows/tests.yml +45 -0
- package/CONTRIBUTING.md +58 -0
- package/LICENSE +21 -0
- package/agenda.md +2 -0
- package/build.mts +34 -0
- package/cucumber.mjs +6 -0
- package/features/attachments.feature +32 -0
- package/features/basic.feature +27 -0
- package/features/data_tables.feature +45 -0
- package/features/description.feature +49 -0
- package/features/errors.feature +28 -0
- package/features/hooks_named.feature +32 -0
- package/features/hooks_unnamed.feature +33 -0
- package/features/locations.feature +37 -0
- package/features/retries.feature +30 -0
- package/features/rules.feature +25 -0
- package/features/scenario_outlines.feature +57 -0
- package/features/scenario_outlines_multiple.feature +44 -0
- package/features/statuses.feature +70 -0
- package/features/stdio.feature +29 -0
- package/features/steps.feature +24 -0
- package/features/support/attachments_steps.ts +32 -0
- package/features/support/basic_steps.ts +235 -0
- package/features/support/description_steps.ts +37 -0
- package/features/support/errors_steps.ts +48 -0
- package/features/support/harness.ts +196 -0
- package/features/support/project_steps.ts +24 -0
- package/features/support/stdio_steps.ts +21 -0
- package/features/support/tags_steps.ts +10 -0
- package/features/tags.feature +19 -0
- package/features/tags_hierarchy.feature +37 -0
- package/package.json +37 -0
- package/plan.md +59 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/formatter.ts +635 -0
- package/tsconfig.json +24 -0
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import type { IFormatterOptions } from '@cucumber/cucumber';
|
|
2
|
+
import { Formatter, formatterHelpers } from '@cucumber/cucumber';
|
|
3
|
+
import type {
|
|
4
|
+
Attachment as CucumberAttachment,
|
|
5
|
+
Duration,
|
|
6
|
+
Envelope,
|
|
7
|
+
Feature,
|
|
8
|
+
GherkinDocument,
|
|
9
|
+
Location,
|
|
10
|
+
Pickle,
|
|
11
|
+
Rule,
|
|
12
|
+
Scenario,
|
|
13
|
+
TestCaseFinished,
|
|
14
|
+
TestCaseStarted,
|
|
15
|
+
TestRunFinished,
|
|
16
|
+
TestRunStarted,
|
|
17
|
+
Timestamp
|
|
18
|
+
} from '@cucumber/messages';
|
|
19
|
+
import { AttachmentContentEncoding, TestStepResultStatus } from '@cucumber/messages';
|
|
20
|
+
import { FlakinessReport as FK } from '@flakiness/flakiness-report';
|
|
21
|
+
import {
|
|
22
|
+
CIUtils,
|
|
23
|
+
CPUUtilization,
|
|
24
|
+
GitWorktree,
|
|
25
|
+
RAMUtilization,
|
|
26
|
+
ReportUtils,
|
|
27
|
+
uploadReport,
|
|
28
|
+
writeReport
|
|
29
|
+
} from '@flakiness/sdk';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
type FormatterConfig = {
|
|
34
|
+
disableUpload?: boolean,
|
|
35
|
+
endpoint?: string,
|
|
36
|
+
flakinessProject?: string,
|
|
37
|
+
outputFolder?: string,
|
|
38
|
+
token?: string,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type LineAndUri = {
|
|
42
|
+
line: number,
|
|
43
|
+
uri: string,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ParsedTestStep = ReturnType<typeof formatterHelpers.parseTestCaseAttempt>['testSteps'][number];
|
|
47
|
+
type ReportDataAttachment = Awaited<ReturnType<typeof ReportUtils.createDataAttachment>>;
|
|
48
|
+
|
|
49
|
+
const CUCUMBER_LOG_MEDIA_TYPE = 'text/x.cucumber.log+plain';
|
|
50
|
+
|
|
51
|
+
export default class FlakinessCucumberFormatter extends Formatter {
|
|
52
|
+
static documentation = 'Generates a Flakiness report for a CucumberJS run.';
|
|
53
|
+
|
|
54
|
+
private _config: FormatterConfig;
|
|
55
|
+
private _cpuUtilization = new CPUUtilization({ precision: 10 });
|
|
56
|
+
private _ramUtilization = new RAMUtilization({ precision: 10 });
|
|
57
|
+
private _startTimestamp = Date.now() as FK.UnixTimestampMS;
|
|
58
|
+
private _outputFolder: string;
|
|
59
|
+
private _telemetryTimer?: NodeJS.Timeout;
|
|
60
|
+
|
|
61
|
+
private _finishedPromise = new ManualPromise();
|
|
62
|
+
private _testCaseStartedById = new Map<string, TestCaseStarted>();
|
|
63
|
+
private _testCaseFinishedById = new Map<string, TestCaseFinished>();
|
|
64
|
+
|
|
65
|
+
constructor(options: IFormatterOptions) {
|
|
66
|
+
super(options);
|
|
67
|
+
this._config = parseFormatterConfig(options.parsedArgvOptions);
|
|
68
|
+
this._outputFolder = path.resolve(this.cwd, this._config.outputFolder ?? process.env.FLAKINESS_OUTPUT_DIR ?? 'flakiness-report');
|
|
69
|
+
|
|
70
|
+
this._sampleSystem = this._sampleSystem.bind(this);
|
|
71
|
+
this._sampleSystem();
|
|
72
|
+
|
|
73
|
+
// Cucumber emits a stream of protocol messages; each message is wrapped
|
|
74
|
+
// in an Envelope and carries one payload such as testRunStarted or attachment.
|
|
75
|
+
options.eventBroadcaster.on('envelope', (envelope: Envelope) => {
|
|
76
|
+
if (envelope.testRunStarted)
|
|
77
|
+
this._onTestRunStarted(envelope.testRunStarted);
|
|
78
|
+
if (envelope.testCaseStarted)
|
|
79
|
+
this._onTestCaseStarted(envelope.testCaseStarted);
|
|
80
|
+
if (envelope.testCaseFinished)
|
|
81
|
+
this._onTestCaseFinished(envelope.testCaseFinished);
|
|
82
|
+
|
|
83
|
+
if (envelope.testRunFinished) {
|
|
84
|
+
this._onTestRunFinished(envelope.testRunFinished)
|
|
85
|
+
.then(() => this._finishedPromise.resolve(undefined))
|
|
86
|
+
.catch((e) => this._finishedPromise.reject(e));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private _onTestRunStarted(testRunStarted: TestRunStarted) {
|
|
92
|
+
this._startTimestamp = toUnixTimestampMS(testRunStarted.timestamp);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _onTestCaseStarted(testCaseStarted: TestCaseStarted) {
|
|
96
|
+
this._testCaseStartedById.set(testCaseStarted.id, testCaseStarted);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private _onTestCaseFinished(testCaseFinished: TestCaseFinished) {
|
|
100
|
+
this._testCaseFinishedById.set(testCaseFinished.testCaseStartedId, testCaseFinished);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override async finished(): Promise<void> {
|
|
104
|
+
if (this._telemetryTimer)
|
|
105
|
+
clearTimeout(this._telemetryTimer);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await this._finishedPromise.promise;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`[flakiness.io] Failed to generate report: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await super.finished();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private _sampleSystem(): void {
|
|
117
|
+
this._cpuUtilization.sample();
|
|
118
|
+
this._ramUtilization.sample();
|
|
119
|
+
this._telemetryTimer = setTimeout(this._sampleSystem, 1000);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async _onTestRunFinished(testRunFinished: TestRunFinished): Promise<void> {
|
|
123
|
+
this._cpuUtilization.sample();
|
|
124
|
+
this._ramUtilization.sample();
|
|
125
|
+
|
|
126
|
+
let worktree: GitWorktree;
|
|
127
|
+
let commitId: FK.CommitId;
|
|
128
|
+
try {
|
|
129
|
+
worktree = GitWorktree.create(this.cwd);
|
|
130
|
+
commitId = worktree.headCommitId();
|
|
131
|
+
} catch {
|
|
132
|
+
console.warn('[flakiness.io] Failed to fetch commit info - is this a git repo?');
|
|
133
|
+
console.error('[flakiness.io] Report is NOT generated.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { attachments, suites } = await this._collectSuites(worktree);
|
|
138
|
+
|
|
139
|
+
const report = ReportUtils.normalizeReport({
|
|
140
|
+
category: 'cucumberjs',
|
|
141
|
+
commitId,
|
|
142
|
+
duration: (toUnixTimestampMS(testRunFinished.timestamp) - this._startTimestamp) as FK.DurationMS,
|
|
143
|
+
environments: [
|
|
144
|
+
ReportUtils.createEnvironment({
|
|
145
|
+
name: 'cucumberjs',
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
flakinessProject: this._config.flakinessProject,
|
|
149
|
+
suites,
|
|
150
|
+
startTimestamp: this._startTimestamp,
|
|
151
|
+
url: CIUtils.runUrl(),
|
|
152
|
+
});
|
|
153
|
+
ReportUtils.collectSources(worktree, report);
|
|
154
|
+
this._cpuUtilization.enrich(report);
|
|
155
|
+
this._ramUtilization.enrich(report);
|
|
156
|
+
|
|
157
|
+
await writeReport(report, attachments, this._outputFolder);
|
|
158
|
+
|
|
159
|
+
const disableUpload = this._config.disableUpload ?? envBool('FLAKINESS_DISABLE_UPLOAD');
|
|
160
|
+
if (!disableUpload) {
|
|
161
|
+
await uploadReport(report, attachments, {
|
|
162
|
+
flakinessAccessToken: this._config.token,
|
|
163
|
+
flakinessEndpoint: this._config.endpoint,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const defaultOutputFolder = path.join(this.cwd, 'flakiness-report');
|
|
168
|
+
const folder = defaultOutputFolder === this._outputFolder ? '' : path.relative(this.cwd, this._outputFolder);
|
|
169
|
+
this.log(`
|
|
170
|
+
To open last Flakiness report, run:
|
|
171
|
+
|
|
172
|
+
npx flakiness show ${folder}
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async _collectSuites(worktree: GitWorktree): Promise<{
|
|
177
|
+
attachments: ReportDataAttachment[],
|
|
178
|
+
suites: FK.Suite[],
|
|
179
|
+
}> {
|
|
180
|
+
const suitesByKey = new Map<string, FK.Suite>();
|
|
181
|
+
const testsById = new Map<string, FK.Test>();
|
|
182
|
+
const attachments = new Map<FK.AttachmentId, ReportDataAttachment>();
|
|
183
|
+
|
|
184
|
+
for (const [testCaseStartedId, testCaseStarted] of this._testCaseStartedById) {
|
|
185
|
+
const attemptData = this.eventDataCollector.getTestCaseAttempt(testCaseStartedId);
|
|
186
|
+
const parsedAttempt = formatterHelpers.parseTestCaseAttempt({
|
|
187
|
+
testCaseAttempt: attemptData,
|
|
188
|
+
snippetBuilder: this.snippetBuilder,
|
|
189
|
+
supportCodeLibrary: this.supportCodeLibrary,
|
|
190
|
+
});
|
|
191
|
+
const featureUri = attemptData.pickle.uri;
|
|
192
|
+
const fileSuite = getOrCreateFileSuite(suitesByKey, worktree, this.cwd, featureUri);
|
|
193
|
+
const featureSuite = getOrCreateFeatureSuite(
|
|
194
|
+
suitesByKey,
|
|
195
|
+
fileSuite,
|
|
196
|
+
worktree,
|
|
197
|
+
this.cwd,
|
|
198
|
+
featureUri,
|
|
199
|
+
attemptData.gherkinDocument,
|
|
200
|
+
);
|
|
201
|
+
const rule = findRuleForPickle(attemptData.gherkinDocument, attemptData.pickle);
|
|
202
|
+
const parentSuite = rule
|
|
203
|
+
? getOrCreateRuleSuite(suitesByKey, featureSuite, worktree, this.cwd, featureUri, rule)
|
|
204
|
+
: featureSuite;
|
|
205
|
+
|
|
206
|
+
let test = testsById.get(attemptData.testCase.id);
|
|
207
|
+
if (!test) {
|
|
208
|
+
test = {
|
|
209
|
+
title: toFKTestTitle(attemptData.gherkinDocument, attemptData.pickle),
|
|
210
|
+
location: attemptData.pickle.location
|
|
211
|
+
? createLocation(worktree, this.cwd, featureUri, attemptData.pickle.location)
|
|
212
|
+
: undefined,
|
|
213
|
+
tags: attemptData.pickle.tags.map(tag => stripTagPrefix(tag.name)),
|
|
214
|
+
attempts: [],
|
|
215
|
+
};
|
|
216
|
+
testsById.set(attemptData.testCase.id, test);
|
|
217
|
+
parentSuite.tests!.push(test);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const testCaseFinished = this._testCaseFinishedById.get(testCaseStartedId);
|
|
221
|
+
const startTimestamp = toUnixTimestampMS(testCaseStarted.timestamp);
|
|
222
|
+
const finishTimestamp = testCaseFinished ? toUnixTimestampMS(testCaseFinished.timestamp) : startTimestamp;
|
|
223
|
+
const errors = parsedAttempt.testSteps
|
|
224
|
+
.map(step => extractErrorFromStep(worktree, this.cwd, step))
|
|
225
|
+
.filter((error): error is FK.ReportError => !!error);
|
|
226
|
+
const stdio = extractSTDIOFromTestSteps(parsedAttempt.testSteps, startTimestamp);
|
|
227
|
+
|
|
228
|
+
test.attempts.push({
|
|
229
|
+
environmentIdx: 0,
|
|
230
|
+
startTimestamp,
|
|
231
|
+
duration: Math.max(0, finishTimestamp - startTimestamp) as FK.DurationMS,
|
|
232
|
+
status: toFKStatus(attemptData.worstTestStepResult.status),
|
|
233
|
+
annotations: extractAttemptAnnotations(worktree, this.cwd, featureUri, attemptData.gherkinDocument, attemptData.pickle),
|
|
234
|
+
errors: errors.length ? errors : undefined,
|
|
235
|
+
attachments: await extractAttachmentsFromTestSteps(parsedAttempt.testSteps, attachments),
|
|
236
|
+
stdio: stdio.length ? stdio : undefined,
|
|
237
|
+
steps: parsedAttempt.testSteps.map(step => ({
|
|
238
|
+
title: toFKStepTitle(step),
|
|
239
|
+
duration: toDurationMS(step.result.duration),
|
|
240
|
+
error: extractErrorFromStep(worktree, this.cwd, step),
|
|
241
|
+
location: step.sourceLocation
|
|
242
|
+
? createLineAndUriLocation(worktree, this.cwd, step.sourceLocation)
|
|
243
|
+
: step.actionLocation
|
|
244
|
+
? createLineAndUriLocation(worktree, this.cwd, step.actionLocation)
|
|
245
|
+
: undefined,
|
|
246
|
+
})),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
attachments: Array.from(attachments.values()),
|
|
252
|
+
suites: Array.from(suitesByKey.values()).filter(suite => suite.type === 'file'),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function envBool(name: string): boolean {
|
|
258
|
+
return ['1', 'true'].includes(process.env[name]?.toLowerCase() ?? '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseFormatterConfig(parsedArgvOptions: IFormatterOptions['parsedArgvOptions']): FormatterConfig {
|
|
262
|
+
return {
|
|
263
|
+
disableUpload: typeof parsedArgvOptions.disableUpload === 'boolean' ? parsedArgvOptions.disableUpload : undefined,
|
|
264
|
+
endpoint: typeof parsedArgvOptions.endpoint === 'string' ? parsedArgvOptions.endpoint : undefined,
|
|
265
|
+
flakinessProject: typeof parsedArgvOptions.flakinessProject === 'string' ? parsedArgvOptions.flakinessProject : undefined,
|
|
266
|
+
outputFolder: typeof parsedArgvOptions.outputFolder === 'string' ? parsedArgvOptions.outputFolder : undefined,
|
|
267
|
+
token: typeof parsedArgvOptions.token === 'string' ? parsedArgvOptions.token : undefined,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function createLocation(worktree: GitWorktree, cwd: string, relativeFile: string, location: Location): FK.Location {
|
|
272
|
+
return {
|
|
273
|
+
file: worktree.gitPath(canonicalizeAbsolutePath(path.resolve(cwd, relativeFile))),
|
|
274
|
+
line: location.line as FK.Number1Based,
|
|
275
|
+
column: (location.column ?? 1) as FK.Number1Based,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stripTagPrefix(tag: string): string {
|
|
280
|
+
return tag.startsWith('@') ? tag.slice(1) : tag;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getOrCreateFileSuite(
|
|
284
|
+
suitesByKey: Map<string, FK.Suite>,
|
|
285
|
+
worktree: GitWorktree,
|
|
286
|
+
cwd: string,
|
|
287
|
+
featureUri: string,
|
|
288
|
+
): FK.Suite {
|
|
289
|
+
const key = `file:${featureUri}`;
|
|
290
|
+
let suite = suitesByKey.get(key);
|
|
291
|
+
if (!suite) {
|
|
292
|
+
suite = {
|
|
293
|
+
type: 'file',
|
|
294
|
+
title: path.basename(featureUri),
|
|
295
|
+
location: createLocation(worktree, cwd, featureUri, { line: 0, column: 0 }),
|
|
296
|
+
suites: [],
|
|
297
|
+
};
|
|
298
|
+
suitesByKey.set(key, suite);
|
|
299
|
+
}
|
|
300
|
+
return suite;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getOrCreateFeatureSuite(
|
|
304
|
+
suitesByKey: Map<string, FK.Suite>,
|
|
305
|
+
fileSuite: FK.Suite,
|
|
306
|
+
worktree: GitWorktree,
|
|
307
|
+
cwd: string,
|
|
308
|
+
featureUri: string,
|
|
309
|
+
gherkinDocument: GherkinDocument,
|
|
310
|
+
): FK.Suite {
|
|
311
|
+
const key = `feature:${featureUri}`;
|
|
312
|
+
let suite = suitesByKey.get(key);
|
|
313
|
+
if (!suite) {
|
|
314
|
+
suite = {
|
|
315
|
+
type: 'suite',
|
|
316
|
+
title: gherkinDocument.feature?.name ?? '',
|
|
317
|
+
location: gherkinDocument.feature?.location
|
|
318
|
+
? createLocation(worktree, cwd, featureUri, gherkinDocument.feature.location)
|
|
319
|
+
: undefined,
|
|
320
|
+
suites: [],
|
|
321
|
+
tests: [],
|
|
322
|
+
};
|
|
323
|
+
suitesByKey.set(key, suite);
|
|
324
|
+
fileSuite.suites!.push(suite);
|
|
325
|
+
}
|
|
326
|
+
return suite;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getOrCreateRuleSuite(
|
|
330
|
+
suitesByKey: Map<string, FK.Suite>,
|
|
331
|
+
featureSuite: FK.Suite,
|
|
332
|
+
worktree: GitWorktree,
|
|
333
|
+
cwd: string,
|
|
334
|
+
featureUri: string,
|
|
335
|
+
rule: Rule,
|
|
336
|
+
): FK.Suite {
|
|
337
|
+
const key = `rule:${featureUri}:${rule.id}`;
|
|
338
|
+
let suite = suitesByKey.get(key);
|
|
339
|
+
if (!suite) {
|
|
340
|
+
suite = {
|
|
341
|
+
type: 'suite',
|
|
342
|
+
title: rule.name,
|
|
343
|
+
location: createLocation(worktree, cwd, featureUri, rule.location),
|
|
344
|
+
tests: [],
|
|
345
|
+
};
|
|
346
|
+
suitesByKey.set(key, suite);
|
|
347
|
+
featureSuite.suites!.push(suite);
|
|
348
|
+
}
|
|
349
|
+
return suite;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function extractAttemptAnnotations(
|
|
353
|
+
worktree: GitWorktree,
|
|
354
|
+
cwd: string,
|
|
355
|
+
featureUri: string,
|
|
356
|
+
gherkinDocument: GherkinDocument,
|
|
357
|
+
pickle: Pickle,
|
|
358
|
+
): FK.Annotation[] | undefined {
|
|
359
|
+
const annotations = [
|
|
360
|
+
createDescriptionAnnotation('feature', worktree, cwd, featureUri, gherkinDocument.feature),
|
|
361
|
+
createDescriptionAnnotation('rule', worktree, cwd, featureUri, findRuleForPickle(gherkinDocument, pickle)),
|
|
362
|
+
createDescriptionAnnotation('scenario', worktree, cwd, featureUri, findScenarioForPickle(gherkinDocument, pickle)),
|
|
363
|
+
].filter((annotation): annotation is FK.Annotation => !!annotation);
|
|
364
|
+
|
|
365
|
+
return annotations.length ? annotations : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function createDescriptionAnnotation(
|
|
369
|
+
type: string,
|
|
370
|
+
worktree: GitWorktree,
|
|
371
|
+
cwd: string,
|
|
372
|
+
featureUri: string,
|
|
373
|
+
node: { description: string, location: Location } | undefined,
|
|
374
|
+
): FK.Annotation | undefined {
|
|
375
|
+
const description = normalizeDescription(node?.description);
|
|
376
|
+
if (!description || !node)
|
|
377
|
+
return undefined;
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
type,
|
|
381
|
+
description,
|
|
382
|
+
location: createLocation(worktree, cwd, featureUri, node.location),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function findRuleForPickle(gherkinDocument: GherkinDocument, pickle: Pickle): Rule | undefined {
|
|
387
|
+
const astNodeIds = new Set(pickle.astNodeIds);
|
|
388
|
+
for (const child of gherkinDocument.feature?.children ?? []) {
|
|
389
|
+
if (!child.rule)
|
|
390
|
+
continue;
|
|
391
|
+
const hasScenario = child.rule.children.some(ruleChild => ruleChild.scenario && astNodeIds.has(ruleChild.scenario.id));
|
|
392
|
+
if (hasScenario)
|
|
393
|
+
return child.rule;
|
|
394
|
+
}
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function findScenarioForPickle(gherkinDocument: GherkinDocument, pickle: Pickle): Scenario | undefined {
|
|
399
|
+
const astNodeIds = new Set(pickle.astNodeIds);
|
|
400
|
+
return collectScenarios(gherkinDocument.feature).find(scenario => astNodeIds.has(scenario.id));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function toFKTestTitle(gherkinDocument: GherkinDocument, pickle: Pickle): string {
|
|
404
|
+
const exampleValues = extractScenarioOutlineValues(gherkinDocument, pickle);
|
|
405
|
+
if (exampleValues)
|
|
406
|
+
return `${pickle.name} [${exampleValues.map(([key, value]) => `${key}=${value}`).join(', ')}]`;
|
|
407
|
+
return pickle.name;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function extractScenarioOutlineValues(gherkinDocument: GherkinDocument, pickle: Pickle): [string, string][] | undefined {
|
|
411
|
+
// `astNodeIds` is the list of Gherkin node IDs that produced it.
|
|
412
|
+
// In practice:
|
|
413
|
+
// - For a normal scenario, it usually includes the scenario node ID.
|
|
414
|
+
// - For a Scenario Outline, it includes the scenario node ID and the selected
|
|
415
|
+
// example-row node ID.
|
|
416
|
+
// - For steps, individual pickleStep.astNodeIds point back to the original Gherkin
|
|
417
|
+
// step nodes.
|
|
418
|
+
if (pickle.astNodeIds.length < 2)
|
|
419
|
+
return undefined;
|
|
420
|
+
|
|
421
|
+
// The last nodeId is the selected example row.
|
|
422
|
+
const exampleRowId = pickle.astNodeIds[pickle.astNodeIds.length - 1];
|
|
423
|
+
|
|
424
|
+
for (const scenario of collectScenarios(gherkinDocument.feature)) {
|
|
425
|
+
for (const examples of scenario.examples) {
|
|
426
|
+
const row = examples.tableBody.find(row => row.id === exampleRowId);
|
|
427
|
+
if (!row)
|
|
428
|
+
continue;
|
|
429
|
+
|
|
430
|
+
const headers = examples.tableHeader?.cells.map(cell => cell.value) ?? [];
|
|
431
|
+
return row.cells.map((cell, index) => [headers[index] ?? `column${index + 1}`, cell.value]);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function collectScenarios(feature: Feature | undefined): Scenario[] {
|
|
439
|
+
return (feature?.children ?? []).flatMap(child => {
|
|
440
|
+
if (child.rule)
|
|
441
|
+
return child.rule.children.flatMap(ruleChild => ruleChild.scenario ? [ruleChild.scenario] : []);
|
|
442
|
+
return child.scenario ? [child.scenario] : [];
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizeDescription(description: string | undefined): string | undefined {
|
|
447
|
+
const value = description?.trim();
|
|
448
|
+
if (!value)
|
|
449
|
+
return undefined;
|
|
450
|
+
|
|
451
|
+
const lines = value.split('\n');
|
|
452
|
+
const commonIndent = lines
|
|
453
|
+
.slice(1)
|
|
454
|
+
.filter(line => line.trim())
|
|
455
|
+
.reduce((indent, line) => Math.min(indent, line.match(/^ */)?.[0].length ?? 0), Number.POSITIVE_INFINITY);
|
|
456
|
+
|
|
457
|
+
if (!Number.isFinite(commonIndent) || commonIndent === 0)
|
|
458
|
+
return value;
|
|
459
|
+
|
|
460
|
+
return [
|
|
461
|
+
lines[0]!,
|
|
462
|
+
...lines.slice(1).map(line => line.slice(commonIndent)),
|
|
463
|
+
].join('\n');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function toFKStatus(status: TestStepResultStatus | undefined): FK.TestStatus {
|
|
467
|
+
switch (status) {
|
|
468
|
+
case TestStepResultStatus.PASSED:
|
|
469
|
+
return 'passed';
|
|
470
|
+
case TestStepResultStatus.SKIPPED:
|
|
471
|
+
return 'skipped';
|
|
472
|
+
case TestStepResultStatus.UNKNOWN:
|
|
473
|
+
return 'interrupted';
|
|
474
|
+
case TestStepResultStatus.PENDING:
|
|
475
|
+
case TestStepResultStatus.UNDEFINED:
|
|
476
|
+
case TestStepResultStatus.AMBIGUOUS:
|
|
477
|
+
case TestStepResultStatus.FAILED:
|
|
478
|
+
return 'failed';
|
|
479
|
+
default:
|
|
480
|
+
return 'interrupted';
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function toUnixTimestampMS(timestamp: Timestamp): FK.UnixTimestampMS {
|
|
485
|
+
return (timestamp.seconds * 1000 + Math.floor(timestamp.nanos / 1_000_000)) as FK.UnixTimestampMS;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function toDurationMS(timestamp: Duration): FK.DurationMS {
|
|
489
|
+
return (timestamp.seconds * 1000 + Math.floor(timestamp.nanos / 1_000_000)) as FK.DurationMS;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function createLineAndUriLocation(worktree: GitWorktree, cwd: string, location: LineAndUri): FK.Location {
|
|
493
|
+
return {
|
|
494
|
+
file: worktree.gitPath(canonicalizeAbsolutePath(path.resolve(cwd, location.uri))),
|
|
495
|
+
line: location.line as FK.Number1Based,
|
|
496
|
+
column: 1 as FK.Number1Based,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function canonicalizeAbsolutePath(absolutePath: string): string {
|
|
501
|
+
try {
|
|
502
|
+
// On Windows the same directory may be spelled in multiple ways, for example
|
|
503
|
+
// `C:\Users\runneradmin\...` vs `C:\Users\RUNNER~1\...`. GitWorktree.gitPath()
|
|
504
|
+
// is purely string-based, so without canonicalization it can think the file is
|
|
505
|
+
// outside the repo and produce paths like `../../../../RUNNER~1/...`.
|
|
506
|
+
return fs.realpathSync.native(absolutePath);
|
|
507
|
+
} catch {
|
|
508
|
+
return absolutePath;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toFKStepTitle(step: ParsedTestStep): string {
|
|
513
|
+
return step.text
|
|
514
|
+
? `${step.keyword}${step.text}`.trim()
|
|
515
|
+
: step.name
|
|
516
|
+
? `${step.keyword} (${step.name})`
|
|
517
|
+
: step.keyword;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function extractErrorFromStep(
|
|
521
|
+
worktree: GitWorktree,
|
|
522
|
+
cwd: string,
|
|
523
|
+
step: ParsedTestStep,
|
|
524
|
+
): FK.ReportError | undefined {
|
|
525
|
+
const status = step.result.status;
|
|
526
|
+
if (
|
|
527
|
+
status === TestStepResultStatus.PASSED ||
|
|
528
|
+
status === TestStepResultStatus.SKIPPED ||
|
|
529
|
+
status === TestStepResultStatus.UNKNOWN
|
|
530
|
+
) {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const message = step.result.exception?.message
|
|
535
|
+
?? step.result.message
|
|
536
|
+
?? (status === TestStepResultStatus.PENDING
|
|
537
|
+
? 'Step is pending'
|
|
538
|
+
: status === TestStepResultStatus.UNDEFINED
|
|
539
|
+
? 'Undefined step'
|
|
540
|
+
: undefined);
|
|
541
|
+
const location = step.sourceLocation
|
|
542
|
+
? createLineAndUriLocation(worktree, cwd, step.sourceLocation)
|
|
543
|
+
: step.actionLocation
|
|
544
|
+
? createLineAndUriLocation(worktree, cwd, step.actionLocation)
|
|
545
|
+
: undefined;
|
|
546
|
+
|
|
547
|
+
return message ? {
|
|
548
|
+
location,
|
|
549
|
+
message,
|
|
550
|
+
stack: step.result.exception?.stackTrace,
|
|
551
|
+
snippet: step.snippet,
|
|
552
|
+
} : undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function extractSTDIOFromTestSteps(
|
|
556
|
+
steps: ParsedTestStep[],
|
|
557
|
+
startTimestamp: FK.UnixTimestampMS,
|
|
558
|
+
): FK.TimedSTDIOEntry[] {
|
|
559
|
+
const stdio: FK.TimedSTDIOEntry[] = [];
|
|
560
|
+
let previousTimestamp = startTimestamp;
|
|
561
|
+
|
|
562
|
+
for (const step of steps) {
|
|
563
|
+
for (const attachment of step.attachments) {
|
|
564
|
+
if (attachment.mediaType !== CUCUMBER_LOG_MEDIA_TYPE)
|
|
565
|
+
continue;
|
|
566
|
+
|
|
567
|
+
const timestamp = attachment.timestamp ? toUnixTimestampMS(attachment.timestamp) : previousTimestamp;
|
|
568
|
+
stdio.push({
|
|
569
|
+
...(attachment.contentEncoding === AttachmentContentEncoding.BASE64 ? {
|
|
570
|
+
buffer: attachment.body
|
|
571
|
+
} : {
|
|
572
|
+
text: attachment.body
|
|
573
|
+
}),
|
|
574
|
+
dts: Math.max(0, timestamp - previousTimestamp) as FK.DurationMS,
|
|
575
|
+
});
|
|
576
|
+
previousTimestamp = timestamp;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return stdio;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function extractAttachmentsFromTestSteps(
|
|
584
|
+
steps: ParsedTestStep[],
|
|
585
|
+
attachments: Map<FK.AttachmentId, ReportDataAttachment>,
|
|
586
|
+
): Promise<FK.Attachment[]> {
|
|
587
|
+
const fkAttachments: FK.Attachment[] = [];
|
|
588
|
+
|
|
589
|
+
for (const step of steps) {
|
|
590
|
+
for (const attachment of step.attachments) {
|
|
591
|
+
if (attachment.mediaType === CUCUMBER_LOG_MEDIA_TYPE)
|
|
592
|
+
continue;
|
|
593
|
+
|
|
594
|
+
const dataAttachment = await ReportUtils.createDataAttachment(
|
|
595
|
+
attachment.mediaType,
|
|
596
|
+
decodeAttachmentBody(attachment),
|
|
597
|
+
);
|
|
598
|
+
attachments.set(dataAttachment.id, dataAttachment);
|
|
599
|
+
fkAttachments.push({
|
|
600
|
+
id: dataAttachment.id,
|
|
601
|
+
name: attachment.fileName ?? `attachment-${fkAttachments.length + 1}`,
|
|
602
|
+
contentType: attachment.mediaType,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return fkAttachments;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function decodeAttachmentBody(attachment: CucumberAttachment): Buffer {
|
|
611
|
+
if (attachment.contentEncoding === AttachmentContentEncoding.BASE64)
|
|
612
|
+
return Buffer.from(attachment.body, 'base64');
|
|
613
|
+
return Buffer.from(attachment.body, 'utf8');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
class ManualPromise<T> {
|
|
617
|
+
readonly promise: Promise<T>;
|
|
618
|
+
private _resolve!: (t: T) => void;
|
|
619
|
+
private _reject!: (err: any) => void;
|
|
620
|
+
|
|
621
|
+
constructor() {
|
|
622
|
+
this.promise = new Promise<T>((resolve, reject) => {
|
|
623
|
+
this._resolve = resolve;
|
|
624
|
+
this._reject = reject;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
resolve(e: T) {
|
|
629
|
+
this._resolve(e);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
reject(e: any) {
|
|
633
|
+
this._reject(e);
|
|
634
|
+
}
|
|
635
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{ "include": [
|
|
2
|
+
"src/**/*.ts",
|
|
3
|
+
"features/**/*.ts"
|
|
4
|
+
],
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"checkJs": true,
|
|
9
|
+
"composite": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"emitDeclarationOnly": true,
|
|
13
|
+
"lib": [ "esnext", "DOM", "DOM.Iterable" ],
|
|
14
|
+
"strict": true,
|
|
15
|
+
"module": "NodeNext",
|
|
16
|
+
"moduleResolution": "NodeNext",
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"noEmit": false,
|
|
19
|
+
"outDir": "./types",
|
|
20
|
+
"strictBindCallApply": true,
|
|
21
|
+
"target": "ESNext",
|
|
22
|
+
"types": [ "node" ]
|
|
23
|
+
}
|
|
24
|
+
}
|