@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { IWorld } from '@cucumber/cucumber';
|
|
2
|
+
import type { FlakinessReport as FK } from '@flakiness/flakiness-report';
|
|
3
|
+
import { readReport } from '@flakiness/sdk';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
export type TestWorld = IWorld & {
|
|
13
|
+
reportResult?: GenerateFlakinessReportResult,
|
|
14
|
+
projectArgs?: string[],
|
|
15
|
+
projectEnv?: Record<string, string | undefined>,
|
|
16
|
+
projectFiles?: ProjectFiles,
|
|
17
|
+
suite?: FK.Suite,
|
|
18
|
+
test?: FK.Test,
|
|
19
|
+
attempt?: FK.RunAttempt,
|
|
20
|
+
attachment?: FK.Attachment,
|
|
21
|
+
step?: FK.TestStep,
|
|
22
|
+
stdio?: FK.TimedSTDIOEntry,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ProjectFiles = Record<string, string>;
|
|
26
|
+
|
|
27
|
+
export type SampleProjectRun = {
|
|
28
|
+
status: number,
|
|
29
|
+
stdout: string,
|
|
30
|
+
stderr: string,
|
|
31
|
+
targetDir: string,
|
|
32
|
+
reportDir: string,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ProjectRunOptions = {
|
|
36
|
+
args?: string[],
|
|
37
|
+
env?: Record<string, string | undefined>,
|
|
38
|
+
formatOptions?: Record<string, unknown>,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type GenerateFlakinessReportResult = Awaited<ReturnType<typeof readReport>> & {
|
|
42
|
+
log: {
|
|
43
|
+
stdout: string,
|
|
44
|
+
stderr: string,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ARTIFACTS_DIR = process.platform === 'darwin'
|
|
49
|
+
? '/private/tmp/flakiness-cucumber'
|
|
50
|
+
: path.join(os.tmpdir(), 'flakiness-cucumber');
|
|
51
|
+
|
|
52
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
|
|
53
|
+
const CUCUMBER_BIN = path.join(PROJECT_ROOT, 'node_modules', '@cucumber', 'cucumber', 'bin', 'cucumber.js');
|
|
54
|
+
const FORMATTER_PATH = path.join(PROJECT_ROOT, 'lib', 'formatter.js');
|
|
55
|
+
const FORMATTER_DESCRIPTOR = JSON.stringify(pathToFileURL(FORMATTER_PATH).href);
|
|
56
|
+
const NODE_MODULES_PATH = path.join(PROJECT_ROOT, 'node_modules');
|
|
57
|
+
|
|
58
|
+
const CLEARED_CI_ENV: Record<string, undefined> = {
|
|
59
|
+
BUILD_BUILDID: undefined,
|
|
60
|
+
BUILD_URL: undefined,
|
|
61
|
+
CI_JOB_URL: undefined,
|
|
62
|
+
GITHUB_REPOSITORY: undefined,
|
|
63
|
+
GITHUB_RUN_ATTEMPT: undefined,
|
|
64
|
+
GITHUB_RUN_ID: undefined,
|
|
65
|
+
GITHUB_SERVER_URL: undefined,
|
|
66
|
+
SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: undefined,
|
|
67
|
+
SYSTEM_TEAMPROJECT: undefined,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const DEFAULT_FILES: ProjectFiles = {
|
|
71
|
+
'package.json': JSON.stringify({
|
|
72
|
+
name: 'sample-cucumber-project',
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
}, null, 2),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function runSampleProject(name: string, files: ProjectFiles, options: ProjectRunOptions = {}): SampleProjectRun {
|
|
78
|
+
const targetDir = path.join(ARTIFACTS_DIR, slugify(name));
|
|
79
|
+
const reportDir = path.join(targetDir, 'flakiness-report');
|
|
80
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
81
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
for (const [filePath, content] of Object.entries({ ...DEFAULT_FILES, ...files })) {
|
|
84
|
+
const fullPath = path.join(targetDir, ...filePath.split('/'));
|
|
85
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
86
|
+
fs.writeFileSync(fullPath, content);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
initGitRepo(targetDir);
|
|
90
|
+
|
|
91
|
+
const result = spawnSync(
|
|
92
|
+
process.execPath,
|
|
93
|
+
[
|
|
94
|
+
CUCUMBER_BIN,
|
|
95
|
+
'features/**/*.feature',
|
|
96
|
+
'--require',
|
|
97
|
+
'features/support/**/*.js',
|
|
98
|
+
...(options.args ?? []),
|
|
99
|
+
'--format',
|
|
100
|
+
FORMATTER_DESCRIPTOR,
|
|
101
|
+
...formatOptionsArgs(options.formatOptions),
|
|
102
|
+
],
|
|
103
|
+
{
|
|
104
|
+
cwd: targetDir,
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
...CLEARED_CI_ENV,
|
|
109
|
+
NODE_PATH: [NODE_MODULES_PATH, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
|
|
110
|
+
...options.env,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
status: result.status ?? 0,
|
|
117
|
+
stdout: result.stdout ?? '',
|
|
118
|
+
stderr: result.stderr ?? '',
|
|
119
|
+
targetDir,
|
|
120
|
+
reportDir,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function generateFlakinessReport(
|
|
125
|
+
name: string,
|
|
126
|
+
files: ProjectFiles,
|
|
127
|
+
options: ProjectRunOptions = {},
|
|
128
|
+
): Promise<GenerateFlakinessReportResult> {
|
|
129
|
+
const run = runSampleProject(name, files, {
|
|
130
|
+
args: options.args,
|
|
131
|
+
env: options.env,
|
|
132
|
+
formatOptions: {
|
|
133
|
+
...options.formatOptions,
|
|
134
|
+
outputFolder: path.join(ARTIFACTS_DIR, slugify(name), 'flakiness-report'),
|
|
135
|
+
disableUpload: true,
|
|
136
|
+
open: 'never',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await waitForReport(run);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...(await readReport(run.reportDir)),
|
|
143
|
+
log: {
|
|
144
|
+
stdout: run.stdout,
|
|
145
|
+
stderr: run.stderr,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function initGitRepo(targetDir: string): void {
|
|
151
|
+
execFileSync('git', ['init'], { cwd: targetDir, stdio: 'pipe' });
|
|
152
|
+
execFileSync('git', ['add', '.'], { cwd: targetDir, stdio: 'pipe' });
|
|
153
|
+
execFileSync(
|
|
154
|
+
'git',
|
|
155
|
+
['-c', 'user.email=john@example.com', '-c', 'user.name=john', '-c', 'commit.gpgsign=false', 'commit', '-m', 'staging'],
|
|
156
|
+
{
|
|
157
|
+
cwd: targetDir,
|
|
158
|
+
stdio: 'pipe',
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function slugify(value: string): string {
|
|
164
|
+
return value
|
|
165
|
+
.replace(/[^.a-zA-Z0-9-]+/g, '-')
|
|
166
|
+
.replace(/-+/g, '-')
|
|
167
|
+
.replace(/^-|-$/g, '')
|
|
168
|
+
.toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatOptionsArgs(formatOptions: Record<string, unknown> | undefined): string[] {
|
|
172
|
+
if (!formatOptions || !Object.keys(formatOptions).length)
|
|
173
|
+
return [];
|
|
174
|
+
return ['--format-options', JSON.stringify(formatOptions)];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function assertCount<T>(elements: T[] | undefined, count: number): T[] {
|
|
178
|
+
assert.equal(elements?.length ?? 0, count);
|
|
179
|
+
return elements!;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function waitForReport(run: SampleProjectRun): Promise<void> {
|
|
183
|
+
const reportPath = path.join(run.reportDir, 'report.json');
|
|
184
|
+
for (let attempt = 0; attempt < 50; ++attempt) {
|
|
185
|
+
if (fs.existsSync(reportPath))
|
|
186
|
+
return;
|
|
187
|
+
await delay(20);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw new Error([
|
|
191
|
+
`Report not found at ${reportPath}`,
|
|
192
|
+
`Exit status: ${run.status}`,
|
|
193
|
+
`STDOUT:\n${run.stdout}`,
|
|
194
|
+
`STDERR:\n${run.stderr}`,
|
|
195
|
+
].join('\n\n'));
|
|
196
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DataTable, Given, When } from '@cucumber/cucumber';
|
|
2
|
+
import type { TestWorld } from './harness.ts';
|
|
3
|
+
import { generateFlakinessReport } from './harness.ts';
|
|
4
|
+
|
|
5
|
+
Given<TestWorld>('the project file {string}:', function(filePath: string, content: string) {
|
|
6
|
+
this.projectFiles ??= {};
|
|
7
|
+
this.projectFiles[filePath] = content;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
Given<TestWorld>('the environment variable {string} is {string}', function(name: string, value: string) {
|
|
11
|
+
this.projectEnv ??= {};
|
|
12
|
+
this.projectEnv[name] = value;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
Given<TestWorld>('the Cucumber arguments are:', function(args: DataTable) {
|
|
16
|
+
this.projectArgs = args.raw().flatMap(row => row);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
When<TestWorld>('I generate the Flakiness report for {string}', async function(name: string) {
|
|
20
|
+
this.reportResult = await generateFlakinessReport(name, this.projectFiles ?? {}, {
|
|
21
|
+
args: this.projectArgs,
|
|
22
|
+
env: this.projectEnv,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Then, When } from '@cucumber/cucumber';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { TestWorld } from './harness.ts';
|
|
4
|
+
import { assertCount } from './harness.ts';
|
|
5
|
+
|
|
6
|
+
When<TestWorld>('I look at the stdio entry #{int}', function(entryIdx: number) {
|
|
7
|
+
this.stdio = this.attempt?.stdio?.[entryIdx - 1];
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
Then<TestWorld>('the attempt contains {int} stdio entries', function(expectedEntries: number) {
|
|
11
|
+
assertCount(this.attempt?.stdio, expectedEntries);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
Then<TestWorld>('the stdio entry has text {string}', function(expectedText: string) {
|
|
15
|
+
assert.ok(this.stdio && 'text' in this.stdio, 'Expected a text stdio entry');
|
|
16
|
+
assert.equal(this.stdio.text, expectedText);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
Then<TestWorld>('the stdio entry happened after the previous stdio entry', function() {
|
|
20
|
+
assert.ok((this.stdio?.dts ?? 0) > 0, 'Expected stdio entry delta to be positive');
|
|
21
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Then } from '@cucumber/cucumber';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { TestWorld } from './harness.ts';
|
|
4
|
+
|
|
5
|
+
Then<TestWorld>('the test has tags {string}', function(tags: string) {
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
[...(this.test?.tags ?? [])].sort(),
|
|
8
|
+
tags.split(',').map(tag => tag.trim()).filter(Boolean).sort(),
|
|
9
|
+
);
|
|
10
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Feature: Tags
|
|
2
|
+
Scenario: captures test tags
|
|
3
|
+
Given the project file "features/tagged.feature":
|
|
4
|
+
"""
|
|
5
|
+
@feature-tag
|
|
6
|
+
Feature: Tagged
|
|
7
|
+
@smoke @fast
|
|
8
|
+
Scenario: it has tags
|
|
9
|
+
Given a passing step
|
|
10
|
+
"""
|
|
11
|
+
And the project file "features/support/steps.js":
|
|
12
|
+
"""
|
|
13
|
+
const { Given } = require('@cucumber/cucumber');
|
|
14
|
+
Given('a passing step', function() {});
|
|
15
|
+
"""
|
|
16
|
+
And the environment variable "BUILD_URL" is "https://ci.example.test/build/123"
|
|
17
|
+
When I generate the Flakiness report for "tagged scenario"
|
|
18
|
+
When I look at the test named "it has tags"
|
|
19
|
+
Then the test has tags "feature-tag, smoke, fast"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Feature: Labels
|
|
2
|
+
Scenario: captures inherited tags across a feature tree
|
|
3
|
+
Given the project file "features/hierarchy-tags.feature":
|
|
4
|
+
"""
|
|
5
|
+
@checkout
|
|
6
|
+
Feature: Checkout flow
|
|
7
|
+
|
|
8
|
+
Scenario: guest checkout
|
|
9
|
+
Given a guest begins checkout
|
|
10
|
+
|
|
11
|
+
@card
|
|
12
|
+
Rule: Card payments
|
|
13
|
+
@visa
|
|
14
|
+
Scenario: paying with a saved card
|
|
15
|
+
Given a saved card is available
|
|
16
|
+
|
|
17
|
+
Scenario: paying with a new card
|
|
18
|
+
Given a new card is entered
|
|
19
|
+
"""
|
|
20
|
+
And the project file "features/support/steps.js":
|
|
21
|
+
"""
|
|
22
|
+
const { Given } = require('@cucumber/cucumber');
|
|
23
|
+
|
|
24
|
+
Given('a guest begins checkout', function() {});
|
|
25
|
+
Given('a saved card is available', function() {});
|
|
26
|
+
Given('a new card is entered', function() {});
|
|
27
|
+
"""
|
|
28
|
+
When I generate the Flakiness report for "hierarchy tags"
|
|
29
|
+
|
|
30
|
+
When I look at the test named "guest checkout"
|
|
31
|
+
Then the test has tags "checkout"
|
|
32
|
+
|
|
33
|
+
When I look at the test named "paying with a saved card"
|
|
34
|
+
Then the test has tags "checkout, card, visa"
|
|
35
|
+
|
|
36
|
+
When I look at the test named "paying with a new card"
|
|
37
|
+
Then the test has tags "checkout, card"
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flakiness/cucumberjs",
|
|
3
|
+
"version": "1.0.0-alpha.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./types/src/formatter.d.ts",
|
|
8
|
+
"import": "./lib/formatter.js",
|
|
9
|
+
"require": "./lib/formatter.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./lib/formatter.js",
|
|
13
|
+
"types": "./types/src/formatter.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "kubik build.mts",
|
|
16
|
+
"test": "cucumber-js",
|
|
17
|
+
"coverage": "c8 --reporter=text --reporter=html --include=lib/formatter.js cucumber-js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@flakiness/flakiness-report": "^0.28.0",
|
|
25
|
+
"@flakiness/sdk": "^2.2.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@cucumber/cucumber": "^12.7.0",
|
|
29
|
+
"@cucumber/messages": "^32.2.0",
|
|
30
|
+
"@types/node": "^25.5.0",
|
|
31
|
+
"c8": "^11.0.0",
|
|
32
|
+
"esbuild": "^0.27.4",
|
|
33
|
+
"kubik": "^0.24.0",
|
|
34
|
+
"tsx": "^4.21.0",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/plan.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Gherkin Compatibility Review
|
|
2
|
+
|
|
3
|
+
Scope:
|
|
4
|
+
- Compared the sample `.feature` files from `/Users/aslushnikov/prog/allure-js/packages/allure-cucumberjs/test/samples/features`
|
|
5
|
+
- Ignored Allure-only runtime/config behavior
|
|
6
|
+
- Focused only on Gherkin/Cucumber constructs that our reporter should handle
|
|
7
|
+
|
|
8
|
+
## Supported, but with caveats
|
|
9
|
+
|
|
10
|
+
- `examples-name-change.feature`
|
|
11
|
+
Outline title with `<a>`, `<b>`, `<c>` should work.
|
|
12
|
+
Caveat: our final test title still appends `[a=..., b=..., c=...]`.
|
|
13
|
+
|
|
14
|
+
- `tags_hierarchy.feature`
|
|
15
|
+
As Gherkin, this is feature tags plus `Rule` plus scenario tags under the rule.
|
|
16
|
+
Caveat: tags are stored without `@`.
|
|
17
|
+
|
|
18
|
+
- `links.feature`
|
|
19
|
+
As Gherkin, this is also just tags plus `Rule`.
|
|
20
|
+
Caveat: tags are stored without `@`.
|
|
21
|
+
|
|
22
|
+
- `stepArguments.feature`
|
|
23
|
+
Substituted step arguments should report correctly.
|
|
24
|
+
Caveat: step titles omit `Given/When/Then`.
|
|
25
|
+
|
|
26
|
+
## Needs work
|
|
27
|
+
|
|
28
|
+
- `dataTable.feature`
|
|
29
|
+
The outline itself is fine.
|
|
30
|
+
Missing: the data table content is not preserved structurally in the report.
|
|
31
|
+
|
|
32
|
+
- `dataTableAndExamples.feature`
|
|
33
|
+
The outline itself is fine.
|
|
34
|
+
Missing: neither the data table nor the examples table is preserved structurally.
|
|
35
|
+
|
|
36
|
+
- `examples-multi.feature`
|
|
37
|
+
Multiple `Examples` blocks should execute fine.
|
|
38
|
+
Missing: example-block identity/name is lost, and we do not preserve examples tables structurally.
|
|
39
|
+
|
|
40
|
+
- `nested/simple.feature`
|
|
41
|
+
Nested path should execute fine.
|
|
42
|
+
Missing: file suite title is only `simple.feature`, so same-basename files would collide visually.
|
|
43
|
+
|
|
44
|
+
- `undefinedStepDefs.feature`
|
|
45
|
+
This probably runs through okay.
|
|
46
|
+
Missing: we do not have direct coverage, and `UNDEFINED` is currently flattened to `failed`.
|
|
47
|
+
|
|
48
|
+
## Clearly missing
|
|
49
|
+
|
|
50
|
+
- `description.feature`
|
|
51
|
+
Feature descriptions and scenario descriptions are not reported at all.
|
|
52
|
+
|
|
53
|
+
## Suggested implementation order
|
|
54
|
+
|
|
55
|
+
1. Add coverage for `undefinedStepDefs.feature`
|
|
56
|
+
2. Add coverage for multiple `Examples` blocks
|
|
57
|
+
3. Preserve feature/scenario descriptions
|
|
58
|
+
4. Preserve data tables and examples tables structurally
|
|
59
|
+
5. Improve nested feature-file identity beyond basename-only file suites
|