@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.
Files changed (38) hide show
  1. package/.github/workflows/flakiness-upload-fork-prs.yml +30 -0
  2. package/.github/workflows/publish-npm.yml +41 -0
  3. package/.github/workflows/tests.yml +45 -0
  4. package/CONTRIBUTING.md +58 -0
  5. package/LICENSE +21 -0
  6. package/agenda.md +2 -0
  7. package/build.mts +34 -0
  8. package/cucumber.mjs +6 -0
  9. package/features/attachments.feature +32 -0
  10. package/features/basic.feature +27 -0
  11. package/features/data_tables.feature +45 -0
  12. package/features/description.feature +49 -0
  13. package/features/errors.feature +28 -0
  14. package/features/hooks_named.feature +32 -0
  15. package/features/hooks_unnamed.feature +33 -0
  16. package/features/locations.feature +37 -0
  17. package/features/retries.feature +30 -0
  18. package/features/rules.feature +25 -0
  19. package/features/scenario_outlines.feature +57 -0
  20. package/features/scenario_outlines_multiple.feature +44 -0
  21. package/features/statuses.feature +70 -0
  22. package/features/stdio.feature +29 -0
  23. package/features/steps.feature +24 -0
  24. package/features/support/attachments_steps.ts +32 -0
  25. package/features/support/basic_steps.ts +235 -0
  26. package/features/support/description_steps.ts +37 -0
  27. package/features/support/errors_steps.ts +48 -0
  28. package/features/support/harness.ts +196 -0
  29. package/features/support/project_steps.ts +24 -0
  30. package/features/support/stdio_steps.ts +21 -0
  31. package/features/support/tags_steps.ts +10 -0
  32. package/features/tags.feature +19 -0
  33. package/features/tags_hierarchy.feature +37 -0
  34. package/package.json +37 -0
  35. package/plan.md +59 -0
  36. package/pnpm-workspace.yaml +2 -0
  37. package/src/formatter.ts +635 -0
  38. package/tsconfig.json +24 -0
@@ -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
+ }