├── .npmrc ├── examples ├── webpack-ts │ ├── tsconfig.json │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.ts │ ├── package.json │ └── cypress.config.ts ├── browserify-cjs │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.js │ ├── package.json │ └── cypress.config.js ├── browserify-esm │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.mjs │ ├── package.json │ └── cypress.config.mjs ├── browserify-ts │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.ts │ ├── package.json │ └── cypress.config.ts ├── esbuild-cjs │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.js │ ├── package.json │ └── cypress.config.js ├── esbuild-esm │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.mjs │ ├── package.json │ └── cypress.config.mjs ├── esbuild-ts │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.ts │ ├── package.json │ └── cypress.config.ts ├── webpack-cjs │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.js │ ├── package.json │ └── cypress.config.js ├── webpack-esm │ ├── .npmrc │ ├── cypress │ │ └── e2e │ │ │ ├── duckduckgo.feature │ │ │ └── duckduckgo.mjs │ ├── package.json │ └── cypress.config.mjs └── readme.md ├── .mocharc.json ├── docs ├── vscode-integration.md ├── localisation.md ├── readme.md ├── faq.md ├── test-configuration.md ├── step-definitions.md ├── quick-start.md ├── state-management.md ├── configuration.md ├── tags.md ├── json-report.md └── cucumber-basics.md ├── lib ├── loader.ts ├── debug.ts ├── tag-parser │ ├── errors.ts │ ├── index.ts │ ├── tokenizer.ts │ └── parser.ts ├── environment-helpers.ts ├── types.ts ├── helpers.ts ├── constants.ts ├── assertions.ts ├── type-guards.ts ├── data_table.ts ├── preprocessor-configuration.test.ts ├── template.ts ├── tag-parser.test.ts ├── index.ts ├── data_table.test.ts ├── methods.ts ├── ast-helpers.ts ├── registry.ts ├── step-definitions.ts ├── step-definitions.test.ts └── add-cucumber-preprocessor-plugin.ts ├── declarations.d.ts ├── methods.ts ├── types ├── index.d.ts ├── testState.ts ├── tsconfig.json └── methods.ts ├── cucumber.json ├── .gitignore ├── .editorconfig ├── cucumber.d.ts ├── features ├── undefined_step.feature ├── parse_error.feature ├── support │ ├── helpers.ts │ ├── world.ts │ └── hooks.ts ├── localisation.feature ├── ambiguous_keywords.feature ├── issues │ ├── 713.feature │ ├── 731.feature │ ├── 724.feature │ ├── 736.feature │ └── 705.feature ├── skip.feature ├── nested_steps.feature ├── suite_options.feature ├── custom_parameter_type.feature ├── unambiguous_step_definitions.feature ├── fixtures │ ├── passed-example.json │ ├── attachments │ │ ├── string.json │ │ └── screenshot.json │ ├── failing-before.json │ ├── failing-after.json │ ├── undefined-steps.json │ ├── failing-step.json │ ├── retried.json │ ├── passed-outline.json │ ├── pending-steps.json │ └── multiple-features.json ├── asynchronous_world.feature ├── step_definitions │ ├── file_steps.ts │ ├── config_steps.ts │ ├── cli_steps.ts │ └── json_steps.ts ├── tags │ ├── spec_filter.feature │ ├── tagged_hooks.feature │ ├── test_filter.feature │ ├── target_specific_scenarios_by_tag.feature │ ├── skip_tag.feature │ └── only_tag.feature ├── configuration_overrides.feature ├── loaders │ ├── esbuild.feature │ ├── browserify.feature │ └── webpack.feature ├── mixing_types.feature ├── step_definitions.feature ├── doc_string.feature ├── hooks_ordering.feature ├── world_example.feature ├── scenario_outlines.feature ├── attachments.feature └── data_table.feature ├── tsconfig.json ├── webpack.ts ├── esbuild.ts ├── LICENSE ├── README.md ├── browserify.ts ├── test └── run-all-specs.ts └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/webpack-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/browserify-cjs/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/browserify-esm/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/browserify-ts/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/esbuild-cjs/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/esbuild-esm/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/esbuild-ts/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/webpack-cjs/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/webpack-esm/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /examples/webpack-ts/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register" 3 | } 4 | -------------------------------------------------------------------------------- /docs/vscode-integration.md: -------------------------------------------------------------------------------- 1 | # Integration with VSCode 2 | 3 | Coming soon. 4 | -------------------------------------------------------------------------------- /lib/loader.ts: -------------------------------------------------------------------------------- 1 | import { compile } from "./template"; 2 | 3 | export default compile; 4 | -------------------------------------------------------------------------------- /declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@cypress/browserify-preprocessor"; 2 | 3 | declare module "pngjs"; 4 | -------------------------------------------------------------------------------- /lib/debug.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | 3 | export default debug("cypress-cucumber-preprocessor"); 4 | -------------------------------------------------------------------------------- /methods.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/methods"; 2 | 3 | export { default as DataTable } from "./lib/data_table"; 4 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Minimum TypeScript Version: 3.9 2 | 3 | /// 4 | /// 5 | -------------------------------------------------------------------------------- /lib/tag-parser/errors.ts: -------------------------------------------------------------------------------- 1 | export class TagError extends Error {} 2 | 3 | export class TagTokenizerError extends TagError {} 4 | 5 | export class TagParserError extends TagError {} 6 | -------------------------------------------------------------------------------- /examples/esbuild-cjs/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/esbuild-esm/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/esbuild-ts/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/webpack-cjs/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/webpack-esm/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/webpack-ts/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/browserify-cjs/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/browserify-esm/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /examples/browserify-ts/cypress/e2e/duckduckgo.feature: -------------------------------------------------------------------------------- 1 | Feature: duckduckgo.com 2 | Scenario: visting the frontpage 3 | When I visit duckduckgo.com 4 | Then I should see a search bar 5 | -------------------------------------------------------------------------------- /lib/environment-helpers.ts: -------------------------------------------------------------------------------- 1 | export function getTags(env: Record): string | null { 2 | const tags = env.TAGS ?? env.tags; 3 | 4 | return tags == null ? null : String(tags); 5 | } 6 | -------------------------------------------------------------------------------- /types/testState.ts: -------------------------------------------------------------------------------- 1 | window.testState.gherkinDocument; // $ExpectType IGherkinDocument 2 | window.testState.pickles; // $ExpectType IPickle[] 3 | window.testState.pickle; // $ExpectType IPickle 4 | -------------------------------------------------------------------------------- /examples/webpack-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/webpack-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/webpack-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/webpack-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cucumber.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": { 3 | "publishQuiet": true, 4 | "format": ["@cucumber/pretty-formatter"], 5 | "requireModule": ["ts-node/register"], 6 | "require": ["features/**/*.ts"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | **/*.d.ts 3 | !examples/**/* 4 | !features/**/* 5 | !cucumber.d.ts 6 | !cucumber.js 7 | !declarations.d.ts 8 | !types/index.d.ts 9 | 10 | # Temporary directory for test execution 11 | tmp/ 12 | -------------------------------------------------------------------------------- /examples/browserify-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/browserify-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/browserify-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/browserify-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/esbuild-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@bahmutov/cypress-esbuild-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/esbuild-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@bahmutov/cypress-esbuild-preprocessor": "latest", 5 | "cypress": "latest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/browserify-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/browserify-preprocessor": "latest", 5 | "cypress": "latest", 6 | "typescript": "latest" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/esbuild-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@bahmutov/cypress-esbuild-preprocessor": "latest", 5 | "cypress": "latest", 6 | "typescript": "latest" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/webpack-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@klaveness/cypress-cucumber-preprocessor": "latest", 4 | "@cypress/webpack-preprocessor": "latest", 5 | "cypress": "latest", 6 | "ts-loader": "latest", 7 | "typescript": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_style = space 5 | indent_size = 2 6 | insert_final_newline = true 7 | 8 | [*.{js, ts}] 9 | max_line_length = 80 10 | bracket_spacing = true 11 | jsx_bracket_same_line = false 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /lib/tag-parser/index.ts: -------------------------------------------------------------------------------- 1 | import Parser from "./parser"; 2 | 3 | export function tagToCypressOptions(tag: string): Cypress.TestConfigOverrides { 4 | return new Parser(tag).parse(); 5 | } 6 | 7 | export function looksLikeOptions(tag: string) { 8 | return tag.includes("("); 9 | } 10 | -------------------------------------------------------------------------------- /cucumber.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@cucumber/cucumber" { 2 | interface IWorld { 3 | tmpDir: string; 4 | verifiedLastRunError: boolean; 5 | lastRun: { 6 | stdout: string; 7 | stderr: string; 8 | output: string; 9 | exitCode: number; 10 | }; 11 | } 12 | } 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "Node", 7 | "resolveJsonModule": true, 8 | "target": "ES2017", 9 | "module": "CommonJS", 10 | "lib": ["dom", "ES2019"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/localisation.md: -------------------------------------------------------------------------------- 1 | # Localisation 2 | 3 | Cucumber keywords have been translated to multiple languages and can be used like shown below. See Cucumber's own [documentation](https://cucumber.io/docs/gherkin/languages/) for supported languages. 4 | 5 | ```gherkin 6 | # language: no 7 | Egenskap: en funksjonalitet 8 | Scenario: et scenario 9 | Gitt et steg 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | * [Quick start](quick-start.md) 4 | * [Cucumber basics](cucumber-basics.md) 5 | * [State management](state-management.md) 6 | * [Step definitions](step-definitions.md) 7 | * [Tags](tags.md) 8 | * [JSON report](json-report.md) 9 | * [Localisation](localisation.md) 10 | * [Configuration](configuration.md) 11 | * [Test configuration](test-configuration.md) 12 | * [Frequently asked questions](faq.md) 13 | -------------------------------------------------------------------------------- /examples/esbuild-esm/cypress/e2e/duckduckgo.mjs: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/esbuild-ts/cypress/e2e/duckduckgo.ts: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/webpack-esm/cypress/e2e/duckduckgo.mjs: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/webpack-ts/cypress/e2e/duckduckgo.ts: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/browserify-esm/cypress/e2e/duckduckgo.mjs: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/browserify-ts/cypress/e2e/duckduckgo.ts: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/esbuild-cjs/cypress/e2e/duckduckgo.js: -------------------------------------------------------------------------------- 1 | const { When, Then } = require("@klaveness/cypress-cucumber-preprocessor"); 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/webpack-cjs/cypress/e2e/duckduckgo.js: -------------------------------------------------------------------------------- 1 | const { When, Then } = require("@klaveness/cypress-cucumber-preprocessor"); 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /features/undefined_step.feature: -------------------------------------------------------------------------------- 1 | Feature: undefined Steps 2 | 3 | Scenario: 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given an undefined step 9 | """ 10 | When I run cypress 11 | Then it fails 12 | And the output should contain 13 | """ 14 | Step implementation missing for: an undefined step 15 | """ 16 | -------------------------------------------------------------------------------- /examples/browserify-cjs/cypress/e2e/duckduckgo.js: -------------------------------------------------------------------------------- 1 | const { When, Then } = require("@klaveness/cypress-cucumber-preprocessor"); 2 | 3 | When("I visit duckduckgo.com", () => { 4 | cy.visit("https://duckduckgo.com/"); 5 | }); 6 | 7 | Then("I should see a search bar", () => { 8 | cy.get("input").should( 9 | "have.attr", 10 | "placeholder", 11 | "Search the web without being tracked" 12 | ); 13 | 14 | assert.deepEqual({}, {}); 15 | }); 16 | -------------------------------------------------------------------------------- /features/parse_error.feature: -------------------------------------------------------------------------------- 1 | Feature: parse errors 2 | 3 | Scenario: tagged rules 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | @tag 8 | Rule: a rule name 9 | """ 10 | When I run cypress 11 | Then it fails 12 | And the output should contain 13 | """ 14 | (3:3): expected: #TagLine, #ScenarioLine, #Comment, #Empty, got 'Rule: a rule name' 15 | """ 16 | 17 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface IParameterTypeDefinition { 2 | name: string; 3 | regexp: RegExp; 4 | transformer: (this: Mocha.Context, ...match: string[]) => T; 5 | } 6 | 7 | export interface IHookBody { 8 | (this: Mocha.Context): void; 9 | } 10 | 11 | export interface IStepDefinitionBody { 12 | (this: Mocha.Context, ...args: T): void; 13 | } 14 | 15 | export type YieldType = T extends Generator 16 | ? R 17 | : never; 18 | -------------------------------------------------------------------------------- /features/support/helpers.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { promises as fs } from "fs"; 3 | import { version as cypressVersion } from "cypress/package.json"; 4 | 5 | export async function writeFile(filePath: string, fileContent: string) { 6 | await fs.mkdir(path.dirname(filePath), { recursive: true }); 7 | await fs.writeFile(filePath, fileContent); 8 | } 9 | 10 | export function isPost10() { 11 | return cypressVersion.startsWith("10."); 12 | } 13 | 14 | export function isPre10() { 15 | return !isPost10(); 16 | } 17 | -------------------------------------------------------------------------------- /lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export function ensureIsAbsolute(root: string, maybeRelativePath: string) { 4 | if (path.isAbsolute(maybeRelativePath)) { 5 | return maybeRelativePath; 6 | } else { 7 | return path.join(root, maybeRelativePath); 8 | } 9 | } 10 | 11 | export function ensureIsRelative(root: string, maybeRelativePath: string) { 12 | if (path.isAbsolute(maybeRelativePath)) { 13 | return path.relative(root, maybeRelativePath); 14 | } else { 15 | return maybeRelativePath; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /features/localisation.feature: -------------------------------------------------------------------------------- 1 | Feature: localisation 2 | Scenario: norwegian 3 | Given a file named "cypress/e2e/a.feature" with: 4 | """ 5 | # language: no 6 | Egenskap: en funksjonalitet 7 | Scenario: et scenario 8 | Gitt et steg 9 | """ 10 | And a file named "cypress/support/step_definitions/steps.js" with: 11 | """ 12 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | Given("et steg", function() {}); 14 | """ 15 | When I run cypress 16 | Then it passes 17 | -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | The examples illustrates using each bundler in each language flavor. 4 | 5 | | | CJS | ESM | TS | 6 | |------------|------------------------|------------------------|-----------------------| 7 | | Browserify | [Link](browserify-cjs) | [Link](browserify-esm) | [Link](browserify-ts) | 8 | | Esbuild | [Link](esbuild-cjs) | [Link](esbuild-esm) | [Link](esbuild-ts) | 9 | | Webpack | [Link](webpack-cjs) | [Link](webpack-esm) | [Link](webpack-ts) | 10 | -------------------------------------------------------------------------------- /features/ambiguous_keywords.feature: -------------------------------------------------------------------------------- 1 | Feature: ambiguous keyword 2 | 3 | Scenario: wrongly keyworded step matching 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/support/step_definitions/steps.js" with: 11 | """ 12 | const { When } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | When("a step", function() {}); 14 | """ 15 | When I run cypress 16 | Then it passes 17 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const TASK_APPEND_MESSAGES = 2 | "cypress-cucumber-preprocessor:append-messages"; 3 | 4 | export const TASK_TEST_STEP_STARTED = 5 | "cypress-cucumber-preprocessor:test-step-started"; 6 | 7 | export const TASK_CREATE_STRING_ATTACHMENT = 8 | "cypress-cucumber-preprocessor:create-string-attachment"; 9 | 10 | export const INTERNAL_PROPERTY_NAME = 11 | "__cypress_cucumber_preprocessor_dont_use_this"; 12 | 13 | export const HOOK_FAILURE_EXPR = 14 | /Because this error occurred during a `[^`]+` hook we are skipping all of the remaining tests\./; 15 | -------------------------------------------------------------------------------- /features/issues/713.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/badeball/cypress-cucumber-preprocessor/issues/713 2 | 3 | Feature: returning chains 4 | Scenario: returning a chain 5 | Given a file named "cypress/e2e/a.feature" with: 6 | """ 7 | Feature: a feature 8 | Scenario: a scenario 9 | Given a step 10 | """ 11 | And a file named "cypress/support/step_definitions/steps.js" with: 12 | """ 13 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 14 | Given("a step", () => cy.log("foo")) 15 | """ 16 | When I run cypress 17 | Then it passes 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true 4 | }, 5 | "compilerOptions": { 6 | "declaration": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "Node", 11 | "resolveJsonModule": true, 12 | "target": "ES2017", 13 | "module": "CommonJS", 14 | "types": ["node", "cypress"], 15 | "lib": ["dom", "ES2019"] 16 | }, 17 | "include": [ 18 | "lib/**/*.ts", 19 | "test/**/*.ts", 20 | "methods.ts", 21 | "declarations.d.ts", 22 | "browserify.ts", 23 | "esbuild.ts", 24 | "webpack.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /features/skip.feature: -------------------------------------------------------------------------------- 1 | Feature: skip 2 | 3 | Scenario: calling skip() 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/support/step_definitions/steps.js" with: 11 | """ 12 | const { When } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | When("a step", function() { 14 | this.skip(); 15 | }); 16 | """ 17 | When I run cypress 18 | Then it passes 19 | And it should appear to have skipped the scenario "a scenario name" 20 | -------------------------------------------------------------------------------- /features/nested_steps.feature: -------------------------------------------------------------------------------- 1 | Feature: nesten steps 2 | 3 | Scenario: invoking step from another step 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a nested step 9 | """ 10 | And a file named "cypress/e2e/a.js" with: 11 | """ 12 | const { Given, Step } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | Given("a nested step", function() { 14 | Step(this, "another step"); 15 | }); 16 | Given("another step", function() {}); 17 | """ 18 | When I run cypress 19 | Then it passes 20 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently asked questions 2 | 3 | ### `--env` / `tags` isn't picked up 4 | 5 | This might be because you're trying to specify `-e / --env` multiple times, but [multiple values should be comma-separated](https://docs.cypress.io/guides/guides/command-line#cypress-run-env-lt-env-gt). 6 | 7 | --- 8 | 9 | ### I get `fs_1.promises.rm is not a function` 10 | 11 | Upgrade your node version to at least [v14.14.0](https://nodejs.org/api/fs.html#fspromisesrmpath-options). 12 | 13 | --- 14 | 15 | ### I get `spawn cucumber-json-formatter ENOENT` 16 | 17 | You need to install `cucumber-json-formatter` **yourself**, as per [documentation](json-report.md). 18 | -------------------------------------------------------------------------------- /features/suite_options.feature: -------------------------------------------------------------------------------- 1 | Feature: suite options 2 | Scenario: suite specific retry 3 | Given a file named "cypress/e2e/a.feature" with: 4 | """ 5 | @retries(2) 6 | Feature: a feature 7 | Scenario: a scenario 8 | Given a step 9 | """ 10 | And a file named "cypress/support/step_definitions/steps.js" with: 11 | """ 12 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | let attempt = 0; 14 | Given("a step", () => { 15 | if (attempt++ === 0) { 16 | throw "some error"; 17 | } 18 | }); 19 | """ 20 | When I run cypress 21 | Then it passes 22 | -------------------------------------------------------------------------------- /features/issues/731.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/badeball/cypress-cucumber-preprocessor/issues/731 2 | 3 | Feature: blank titles 4 | Scenario: blank scenario outline title 5 | Given a file named "cypress/e2e/a.feature" with: 6 | """ 7 | Feature: a feature 8 | Scenario Outline: 9 | Given a step 10 | 11 | Examples: 12 | | value | 13 | | foo | 14 | """ 15 | And a file named "cypress/support/step_definitions/steps.js" with: 16 | """ 17 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 18 | Given("a step", () => {}) 19 | """ 20 | When I run cypress 21 | Then it passes 22 | -------------------------------------------------------------------------------- /examples/browserify-esm/cypress.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import preprocessor from "@klaveness/cypress-cucumber-preprocessor"; 3 | import browserify from "@klaveness/cypress-cucumber-preprocessor/browserify.js"; 4 | 5 | export async function setupNodeEvents(on, config) { 6 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 7 | 8 | on("file:preprocessor", browserify.default(config)); 9 | 10 | // Make sure to return the config object as it might have been modified by the plugin. 11 | return config; 12 | } 13 | 14 | export default defineConfig({ 15 | e2e: { 16 | specPattern: "**/*.feature", 17 | supportFile: false, 18 | setupNodeEvents, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /examples/browserify-cjs/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | const preprocessor = require("@klaveness/cypress-cucumber-preprocessor"); 3 | const browserify = require("@klaveness/cypress-cucumber-preprocessor/browserify"); 4 | 5 | async function setupNodeEvents(on, config) { 6 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 7 | 8 | on("file:preprocessor", browserify.default(config)); 9 | 10 | // Make sure to return the config object as it might have been modified by the plugin. 11 | return config; 12 | } 13 | 14 | module.exports = defineConfig({ 15 | e2e: { 16 | specPattern: "**/*.feature", 17 | supportFile: false, 18 | setupNodeEvents, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /webpack.ts: -------------------------------------------------------------------------------- 1 | import { compile } from "./lib/template"; 2 | 3 | /** 4 | * Can be removed once Webpack v5 is supported by Cypress' preprocessor, because Webpack v5 ships 5 | * with these types, ref. https://github.com/webpack/webpack/pull/13164. 6 | */ 7 | interface ILoaderContext { 8 | async: () => (err: Error | null, result?: string) => void; 9 | resourcePath: string; 10 | query: any; 11 | } 12 | 13 | interface ILoaderDefinition { 14 | (this: ILoaderContext, data: string): void; 15 | } 16 | 17 | const loader: ILoaderDefinition = function (data) { 18 | const callback = this.async(); 19 | 20 | compile(this.query, data, this.resourcePath).then( 21 | (result) => callback(null, result), 22 | (error) => callback(error) 23 | ); 24 | }; 25 | 26 | export default loader; 27 | -------------------------------------------------------------------------------- /features/custom_parameter_type.feature: -------------------------------------------------------------------------------- 1 | Feature: custom parameter type 2 | Scenario: definition after usage 3 | Given a file named "cypress/e2e/a.feature" with: 4 | """ 5 | Feature: a feature 6 | Scenario: a scenario 7 | Given a step in blue 8 | """ 9 | And a file named "cypress/support/step_definitions/steps.js" with: 10 | """ 11 | const { Given, defineParameterType } = require("@klaveness/cypress-cucumber-preprocessor"); 12 | Given("a step in {color}", function(color) {}); 13 | defineParameterType({ 14 | name: "color", 15 | regexp: /red|yellow|blue/, 16 | transformer(color) { 17 | return color; 18 | } 19 | }); 20 | """ 21 | When I run cypress 22 | Then it passes 23 | -------------------------------------------------------------------------------- /features/unambiguous_step_definitions.feature: -------------------------------------------------------------------------------- 1 | Feature: unambiguous step definitions 2 | 3 | Scenario: step matching two definitions 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/support/step_definitions/steps.js" with: 11 | """ 12 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 13 | Given("a step", function() {}); 14 | Given(/a step/, function() {}); 15 | """ 16 | When I run cypress 17 | Then it fails 18 | And the output should contain 19 | """ 20 | Error: Multiple matching step definitions for: a step 21 | a step 22 | /a step/ 23 | """ 24 | -------------------------------------------------------------------------------- /lib/assertions.ts: -------------------------------------------------------------------------------- 1 | import { isString } from "./type-guards"; 2 | 3 | import { homepage } from "../package.json"; 4 | 5 | export function fail(message: string) { 6 | throw new Error( 7 | `${message} (this might be a bug, please report at ${homepage})` 8 | ); 9 | } 10 | 11 | export function assert(value: any, message: string): asserts value { 12 | if (value) { 13 | return; 14 | } 15 | 16 | fail(message); 17 | } 18 | 19 | export function assertAndReturn( 20 | value: T, 21 | message: string 22 | ): Exclude { 23 | assert(value, message); 24 | return value as Exclude; 25 | } 26 | 27 | export function assertIsString( 28 | value: any, 29 | message: string 30 | ): asserts value is string { 31 | assert(isString(value), message); 32 | } 33 | -------------------------------------------------------------------------------- /lib/type-guards.ts: -------------------------------------------------------------------------------- 1 | export function isString(value: unknown): value is string { 2 | return typeof value === "string"; 3 | } 4 | 5 | export function isBoolean(value: unknown): value is boolean { 6 | return typeof value === "boolean"; 7 | } 8 | 9 | export function isFalse(value: unknown): value is false { 10 | return value === false; 11 | } 12 | 13 | export function isStringOrFalse(value: unknown): value is string | false { 14 | return isString(value) || isFalse(value); 15 | } 16 | 17 | export function isStringOrStringArray( 18 | value: unknown 19 | ): value is string | string[] { 20 | return ( 21 | typeof value === "string" || (Array.isArray(value) && value.every(isString)) 22 | ); 23 | } 24 | 25 | export function notNull(value: T | null | undefined): value is T { 26 | return value != null; 27 | } 28 | -------------------------------------------------------------------------------- /esbuild.ts: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | 3 | import { ICypressConfiguration } from "@klaveness/cypress-configuration"; 4 | 5 | import { compile } from "./lib/template"; 6 | 7 | export { ICypressConfiguration }; 8 | 9 | export function createEsbuildPlugin( 10 | configuration: ICypressConfiguration 11 | ): esbuild.Plugin { 12 | return { 13 | name: "feature", 14 | setup(build) { 15 | const fs = require("fs") as typeof import("fs"); 16 | 17 | build.onLoad({ filter: /\.feature$/ }, async (args) => { 18 | const content = await fs.promises.readFile(args.path, "utf8"); 19 | 20 | return { 21 | contents: await compile(configuration, content, args.path), 22 | loader: "js", 23 | }; 24 | }); 25 | }, 26 | }; 27 | } 28 | 29 | export default createEsbuildPlugin; 30 | -------------------------------------------------------------------------------- /features/fixtures/passed-example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | } 24 | ], 25 | "type": "scenario" 26 | } 27 | ], 28 | "id": "a-feature", 29 | "keyword": "Feature", 30 | "line": 1, 31 | "name": "a feature", 32 | "uri": "cypress/e2e/a.feature" 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /examples/esbuild-esm/cypress.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; 3 | import preprocessor from "@klaveness/cypress-cucumber-preprocessor"; 4 | import createEsbuildPlugin from "@klaveness/cypress-cucumber-preprocessor/esbuild.js"; 5 | 6 | export async function setupNodeEvents(on, config) { 7 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 8 | 9 | on( 10 | "file:preprocessor", 11 | createBundler({ 12 | plugins: [createEsbuildPlugin.default(config)], 13 | }) 14 | ); 15 | 16 | // Make sure to return the config object as it might have been modified by the plugin. 17 | return config; 18 | } 19 | 20 | export default defineConfig({ 21 | e2e: { 22 | specPattern: "**/*.feature", 23 | supportFile: false, 24 | setupNodeEvents, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /examples/esbuild-cjs/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 3 | const preprocessor = require("@klaveness/cypress-cucumber-preprocessor"); 4 | const createEsbuildPlugin = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 5 | 6 | async function setupNodeEvents(on, config) { 7 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 8 | 9 | on( 10 | "file:preprocessor", 11 | createBundler({ 12 | plugins: [createEsbuildPlugin.default(config)], 13 | }) 14 | ); 15 | 16 | // Make sure to return the config object as it might have been modified by the plugin. 17 | return config; 18 | } 19 | 20 | module.exports = defineConfig({ 21 | e2e: { 22 | specPattern: "**/*.feature", 23 | supportFile: false, 24 | setupNodeEvents, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /features/issues/724.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/badeball/cypress-cucumber-preprocessor/issues/724 2 | 3 | Feature: outputting merely messages 4 | Scenario: enabling *only* messages 5 | Given additional preprocessor configuration 6 | """ 7 | { 8 | "messages": { 9 | "enabled": true 10 | } 11 | } 12 | """ 13 | And a file named "cypress/e2e/a.feature" with: 14 | """ 15 | Feature: a feature 16 | Scenario: a scenario 17 | Given a step 18 | """ 19 | And a file named "cypress/support/step_definitions/steps.js" with: 20 | """ 21 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 22 | Given("a step", function() {}) 23 | """ 24 | When I run cypress 25 | Then it passes 26 | And there should be no JSON output 27 | But there should be a messages report 28 | -------------------------------------------------------------------------------- /examples/browserify-ts/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import { addCucumberPreprocessorPlugin } from "@klaveness/cypress-cucumber-preprocessor"; 3 | import browserify from "@klaveness/cypress-cucumber-preprocessor/browserify"; 4 | 5 | async function setupNodeEvents( 6 | on: Cypress.PluginEvents, 7 | config: Cypress.PluginConfigOptions 8 | ): Promise { 9 | await addCucumberPreprocessorPlugin(on, config); 10 | 11 | on( 12 | "file:preprocessor", 13 | browserify(config, { 14 | typescript: require.resolve("typescript"), 15 | }) 16 | ); 17 | 18 | // Make sure to return the config object as it might have been modified by the plugin. 19 | return config; 20 | } 21 | 22 | export default defineConfig({ 23 | e2e: { 24 | specPattern: "**/*.feature", 25 | supportFile: false, 26 | setupNodeEvents, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /features/asynchronous_world.feature: -------------------------------------------------------------------------------- 1 | Feature: asynchronous world 2 | 3 | Scenario: Assigning to world asynchronously 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step asynchronously assigning to World 9 | And a step accessing said assignment synchronously 10 | """ 11 | And a file named "cypress/support/step_definitions/steps.js" with: 12 | """ 13 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 14 | Given("a step asynchronously assigning to World", function() { 15 | cy.then(() => { 16 | this.foo = "bar"; 17 | }); 18 | }); 19 | Given("a step accessing said assignment synchronously", function() { 20 | expect(this.foo).to.equal("bar"); 21 | }); 22 | """ 23 | When I run cypress 24 | Then it passes 25 | -------------------------------------------------------------------------------- /examples/esbuild-ts/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; 3 | import { addCucumberPreprocessorPlugin } from "@klaveness/cypress-cucumber-preprocessor"; 4 | import createEsbuildPlugin from "@klaveness/cypress-cucumber-preprocessor/esbuild"; 5 | 6 | async function setupNodeEvents( 7 | on: Cypress.PluginEvents, 8 | config: Cypress.PluginConfigOptions 9 | ): Promise { 10 | await addCucumberPreprocessorPlugin(on, config); 11 | 12 | on( 13 | "file:preprocessor", 14 | createBundler({ 15 | plugins: [createEsbuildPlugin(config)], 16 | }) 17 | ); 18 | 19 | // Make sure to return the config object as it might have been modified by the plugin. 20 | return config; 21 | } 22 | 23 | export default defineConfig({ 24 | e2e: { 25 | specPattern: "**/*.feature", 26 | supportFile: false, 27 | setupNodeEvents, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /features/step_definitions/file_steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from "@cucumber/cucumber"; 2 | import stripIndent from "strip-indent"; 3 | import path from "path"; 4 | import { isPost10, isPre10, writeFile } from "../support/helpers"; 5 | 6 | Given("a file named {string} with:", async function (filePath, fileContent) { 7 | const absoluteFilePath = path.join(this.tmpDir, filePath); 8 | 9 | await writeFile(absoluteFilePath, stripIndent(fileContent)); 10 | }); 11 | 12 | Given( 13 | "a file named {string} or {string} \\(depending on Cypress era) with:", 14 | async function (filePathPre10, filePathPost10, fileContent) { 15 | await writeFile( 16 | path.join(this.tmpDir, isPre10() ? filePathPre10 : filePathPost10), 17 | stripIndent(fileContent) 18 | ); 19 | } 20 | ); 21 | 22 | Given("an empty file named {string}", async function (filePath) { 23 | const absoluteFilePath = path.join(this.tmpDir, filePath); 24 | 25 | await writeFile(absoluteFilePath, ""); 26 | }); 27 | -------------------------------------------------------------------------------- /features/tags/spec_filter.feature: -------------------------------------------------------------------------------- 1 | Feature: filter spec 2 | 3 | Scenario: 1 / 2 specs matching 4 | Given additional preprocessor configuration 5 | """ 6 | { 7 | "filterSpecs": true 8 | } 9 | """ 10 | And a file named "cypress/e2e/a.feature" with: 11 | """ 12 | @foo 13 | Feature: some feature 14 | Scenario: first scenario 15 | Given a step 16 | """ 17 | And a file named "cypress/e2e/b.feature" with: 18 | """ 19 | @bar 20 | Feature: some other feature 21 | Scenario: second scenario 22 | Given a step 23 | """ 24 | And a file named "cypress/support/step_definitions/steps.js" with: 25 | """ 26 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 27 | Given("a step", function() {}) 28 | """ 29 | When I run cypress with "--env tags=@foo" 30 | Then it passes 31 | And it should appear to not have ran spec "b.feature" 32 | -------------------------------------------------------------------------------- /features/issues/736.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/badeball/cypress-cucumber-preprocessor/issues/736 2 | 3 | Feature: create output directories 4 | Background: 5 | Given additional preprocessor configuration 6 | """ 7 | { 8 | "messages": { 9 | "enabled": true, 10 | "output": "foo/cucumber-messages.ndjson" 11 | }, 12 | "json": { 13 | "enabled": true, 14 | "output": "bar/cucumber-report.json" 15 | } 16 | } 17 | """ 18 | And I've ensured cucumber-json-formatter is installed 19 | 20 | Scenario: 21 | Given a file named "cypress/e2e/a.feature" with: 22 | """ 23 | Feature: a feature 24 | Scenario: a scenario 25 | Given a step 26 | """ 27 | And a file named "cypress/support/step_definitions/steps.js" with: 28 | """ 29 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 30 | Given("a step", function() {}) 31 | """ 32 | When I run cypress 33 | Then it passes 34 | -------------------------------------------------------------------------------- /features/fixtures/attachments/string.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | }, 23 | "embeddings": [ 24 | { 25 | "data": "Zm9vYmFy", 26 | "mime_type": "text/plain" 27 | } 28 | ] 29 | } 30 | ], 31 | "type": "scenario" 32 | } 33 | ], 34 | "id": "a-feature", 35 | "keyword": "Feature", 36 | "line": 1, 37 | "name": "a feature", 38 | "uri": "cypress/e2e/a.feature" 39 | } 40 | ] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jonas Amundsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /features/fixtures/failing-before.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "before": [ 12 | { 13 | "result": { 14 | "status": "failed", 15 | "error_message": "some error" 16 | }, 17 | "match": { 18 | "location": "not available:0" 19 | } 20 | } 21 | ], 22 | "steps": [ 23 | { 24 | "keyword": "Given ", 25 | "line": 3, 26 | "name": "a step", 27 | "result": { 28 | "status": "skipped" 29 | }, 30 | "match": { 31 | "location": "not available:0" 32 | } 33 | } 34 | ], 35 | "type": "scenario" 36 | } 37 | ], 38 | "id": "a-feature", 39 | "keyword": "Feature", 40 | "line": 1, 41 | "name": "a feature", 42 | "uri": "cypress/e2e/a.feature" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /features/fixtures/failing-after.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | } 24 | ], 25 | "after": [ 26 | { 27 | "result": { 28 | "status": "failed", 29 | "error_message": "some error" 30 | }, 31 | "match": { 32 | "location": "not available:0" 33 | } 34 | } 35 | ], 36 | "type": "scenario" 37 | } 38 | ], 39 | "id": "a-feature", 40 | "keyword": "Feature", 41 | "line": 1, 42 | "name": "a feature", 43 | "uri": "cypress/e2e/a.feature" 44 | } 45 | ] 46 | -------------------------------------------------------------------------------- /features/fixtures/undefined-steps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "an undefined step", 16 | "result": { 17 | "status": "undefined" 18 | }, 19 | "match": { 20 | "location": "not available:0" 21 | } 22 | }, 23 | { 24 | "keyword": "And ", 25 | "line": 4, 26 | "name": "another step", 27 | "result": { 28 | "status": "skipped" 29 | }, 30 | "match": { 31 | "location": "not available:0" 32 | } 33 | } 34 | ], 35 | "type": "scenario" 36 | } 37 | ], 38 | "id": "a-feature", 39 | "keyword": "Feature", 40 | "line": 1, 41 | "name": "a feature", 42 | "uri": "cypress/e2e/a.feature" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /docs/test-configuration.md: -------------------------------------------------------------------------------- 1 | # Test configuration 2 | 3 | Some of Cypress' [configuration options](https://docs.cypress.io/guides/references/configuration) can be overridden per-test by leveraging tags. Below are all supported configuration options shown. 4 | 5 | ```gherkin 6 | @animationDistanceThreshold(5) 7 | @blockHosts('http://www.foo.com','http://www.bar.com') 8 | @defaultCommandTimeout(5) 9 | @execTimeout(5) 10 | @includeShadowDom(true) 11 | @includeShadowDom(false) 12 | @keystrokeDelay(5) 13 | @numTestsKeptInMemory(5) 14 | @pageLoadTimeout(5) 15 | @redirectionLimit(5) 16 | @requestTimeout(5) 17 | @responseTimeout(5) 18 | @retries(5) 19 | @retries(runMode=5) 20 | @retries(openMode=5) 21 | @retries(runMode=5,openMode=10) 22 | @retries(openMode=10,runMode=5) 23 | @screenshotOnRunFailure(true) 24 | @screenshotOnRunFailure(false) 25 | @scrollBehavior('center') 26 | @scrollBehavior('top') 27 | @scrollBehavior('bottom') 28 | @scrollBehavior('nearest') 29 | @slowTestThreshold(5) 30 | @viewportHeight(720) 31 | @viewportWidth(1280) 32 | @waitForAnimations(true) 33 | @waitForAnimations(false) 34 | Feature: a feature 35 | Scenario: a scenario 36 | Given a table step 37 | ``` 38 | -------------------------------------------------------------------------------- /features/fixtures/attachments/screenshot.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | }, 23 | "embeddings": [ 24 | { 25 | "data": "iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAM0lEQVR4Aa3BAQEAAAiDMKR/51uC7QYjJDGJSUxiEpOYxCQmMYlJTGISk5jEJCYxiUnsARwEAibDACoRAAAAAElFTkSuQmCC", 26 | "mime_type": "image/png" 27 | } 28 | ] 29 | } 30 | ], 31 | "type": "scenario" 32 | } 33 | ], 34 | "id": "a-feature", 35 | "keyword": "Feature", 36 | "line": 1, 37 | "name": "a feature", 38 | "uri": "cypress/e2e/a.feature" 39 | } 40 | ] 41 | -------------------------------------------------------------------------------- /features/fixtures/failing-step.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a failing step", 16 | "result": { 17 | "status": "failed", 18 | "error_message": "some error" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | }, 24 | { 25 | "keyword": "And ", 26 | "line": 4, 27 | "name": "another step", 28 | "result": { 29 | "status": "skipped" 30 | }, 31 | "match": { 32 | "location": "not available:0" 33 | } 34 | } 35 | ], 36 | "type": "scenario" 37 | } 38 | ], 39 | "id": "a-feature", 40 | "keyword": "Feature", 41 | "line": 1, 42 | "name": "a feature", 43 | "uri": "cypress/e2e/a.feature" 44 | } 45 | ] 46 | -------------------------------------------------------------------------------- /features/configuration_overrides.feature: -------------------------------------------------------------------------------- 1 | Feature: configuration overrides 2 | Scenario: overriding stepDefinitions through -e 3 | Given a file named "cypress/e2e/a.feature" with: 4 | """ 5 | Feature: a feature name 6 | Scenario: a scenario name 7 | Given a step 8 | """ 9 | And a file named "foobar.js" with: 10 | """ 11 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 12 | Given("a step", function() {}); 13 | """ 14 | When I run cypress with "-e stepDefinitions=foobar.js" 15 | Then it passes 16 | 17 | Scenario: overriding stepDefinitions through environment variables 18 | Given a file named "cypress/e2e/a.feature" with: 19 | """ 20 | Feature: a feature name 21 | Scenario: a scenario name 22 | Given a step 23 | """ 24 | And a file named "foobar.js" with: 25 | """ 26 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 27 | Given("a step", function() {}); 28 | """ 29 | When I run cypress with environment variables 30 | | Key | Value | 31 | | CYPRESS_stepDefinitions | foobar.js | 32 | Then it passes 33 | -------------------------------------------------------------------------------- /features/loaders/esbuild.feature: -------------------------------------------------------------------------------- 1 | @no-default-plugin 2 | Feature: esbuild + typescript 3 | Scenario: 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/plugins/index.js" or "setupNodeEvents.js" (depending on Cypress era) with: 11 | """ 12 | <<<<<<< HEAD 13 | import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; 14 | import { createEsbuildPlugin } from "@klaveness/cypress-cucumber-preprocessor/esbuild"; 15 | ======= 16 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 17 | const { createEsbuildPlugin } = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 18 | >>>>>>> master 19 | 20 | module.exports = async (on, config) => { 21 | on( 22 | "file:preprocessor", 23 | createBundler({ 24 | plugins: [createEsbuildPlugin(config)] 25 | }) 26 | ); 27 | }; 28 | """ 29 | And a file named "cypress/support/step_definitions/steps.ts" with: 30 | """ 31 | import { Given } from "@klaveness/cypress-cucumber-preprocessor"; 32 | Given("a step", function(this: Mocha.Context) {}); 33 | """ 34 | When I run cypress 35 | Then it passes 36 | -------------------------------------------------------------------------------- /features/loaders/browserify.feature: -------------------------------------------------------------------------------- 1 | @no-default-plugin 2 | Feature: browserify + typescript 3 | Scenario: 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/plugins/index.js" or "setupNodeEvents.js" (depending on Cypress era) with: 11 | """ 12 | <<<<<<< HEAD 13 | import browserify from "@cypress/browserify-preprocessor"; 14 | import { preprocessor } from "@klaveness/cypress-cucumber-preprocessor/browserify"; 15 | ======= 16 | const browserify = require("@cypress/browserify-preprocessor"); 17 | const { preprocessor } = require("@klaveness/cypress-cucumber-preprocessor/browserify"); 18 | >>>>>>> master 19 | 20 | module.exports = async (on, config) => { 21 | on( 22 | "file:preprocessor", 23 | preprocessor(config, { 24 | ...browserify.defaultOptions, 25 | typescript: require.resolve("typescript") 26 | }) 27 | ); 28 | }; 29 | """ 30 | And a file named "cypress/support/step_definitions/steps.ts" with: 31 | """ 32 | import { Given } from "@klaveness/cypress-cucumber-preprocessor"; 33 | Given("a step", function(this: Mocha.Context) {}); 34 | """ 35 | When I run cypress 36 | Then it passes 37 | -------------------------------------------------------------------------------- /docs/step-definitions.md: -------------------------------------------------------------------------------- 1 | # Step definitions 2 | 3 | Step definitions are resolved using search paths that are configurable through the `stepDefinitions` property. The preprocessor uses [cosmiconfig](https://github.com/davidtheclark/cosmiconfig), which means you can place configuration options in EG. `.cypress-cucumber-preprocessorrc.json` or `package.json`. The default search paths are shown below. 4 | 5 | ```json 6 | { 7 | "stepDefinitions": [ 8 | "[filepath]/**/*.{js,ts}", 9 | "[filepath].{js,ts}", 10 | "cypress/support/step_definitions/**/*.{js,ts}", 11 | ] 12 | } 13 | ``` 14 | 15 | This means that if you have a file `cypress/e2e/duckduckgo.feature`, it will match step definitions found in 16 | 17 | * `cypress/e2e/duckduckgo/steps.ts` 18 | * `cypress/e2e/duckduckgo.ts` 19 | * `cypress/support/step_definitions/duckduckgo.ts` 20 | 21 | ## Hierarchy 22 | 23 | There's also a `[filepart]` option available. Given a configuration shown below 24 | 25 | ```json 26 | { 27 | "stepDefinitions": [ 28 | "[filepart]/step_definitions/**/*.{js,ts}" 29 | ] 30 | } 31 | ``` 32 | 33 | ... and a feature file `cypress/e2e/foo/bar/baz.feature`, the preprocessor would look for step definitions in 34 | 35 | * `cypress/e2e/foo/bar/baz/step_definitions/**/*.{js,ts}` 36 | * `cypress/e2e/foo/bar/step_definitions/**/*.{js,ts}` 37 | * `cypress/e2e/foo/step_definitions/**/*.{js,ts}` 38 | * `cypress/e2e/step_definitions/**/*.{js,ts}` 39 | -------------------------------------------------------------------------------- /examples/webpack-esm/cypress.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import webpack from "@cypress/webpack-preprocessor"; 3 | import preprocessor from "@klaveness/cypress-cucumber-preprocessor"; 4 | 5 | export async function setupNodeEvents(on, config) { 6 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 7 | 8 | on( 9 | "file:preprocessor", 10 | webpack({ 11 | webpackOptions: { 12 | resolve: { 13 | extensions: [".ts", ".js"], 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ts$/, 19 | exclude: [/node_modules/], 20 | use: [ 21 | { 22 | loader: "ts-loader", 23 | }, 24 | ], 25 | }, 26 | { 27 | test: /\.feature$/, 28 | use: [ 29 | { 30 | loader: "@klaveness/cypress-cucumber-preprocessor/webpack", 31 | options: config, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | }, 38 | }) 39 | ); 40 | 41 | // Make sure to return the config object as it might have been modified by the plugin. 42 | return config; 43 | } 44 | 45 | export default defineConfig({ 46 | e2e: { 47 | specPattern: "**/*.feature", 48 | supportFile: false, 49 | setupNodeEvents, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /examples/webpack-cjs/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | const webpack = require("@cypress/webpack-preprocessor"); 3 | const preprocessor = require("@klaveness/cypress-cucumber-preprocessor"); 4 | 5 | async function setupNodeEvents(on, config) { 6 | await preprocessor.addCucumberPreprocessorPlugin(on, config); 7 | 8 | on( 9 | "file:preprocessor", 10 | webpack({ 11 | webpackOptions: { 12 | resolve: { 13 | extensions: [".ts", ".js"], 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ts$/, 19 | exclude: [/node_modules/], 20 | use: [ 21 | { 22 | loader: "ts-loader", 23 | }, 24 | ], 25 | }, 26 | { 27 | test: /\.feature$/, 28 | use: [ 29 | { 30 | loader: "@klaveness/cypress-cucumber-preprocessor/webpack", 31 | options: config, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | }, 38 | }) 39 | ); 40 | 41 | // Make sure to return the config object as it might have been modified by the plugin. 42 | return config; 43 | } 44 | 45 | module.exports = defineConfig({ 46 | e2e: { 47 | specPattern: "**/*.feature", 48 | supportFile: false, 49 | setupNodeEvents, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /features/mixing_types.feature: -------------------------------------------------------------------------------- 1 | Feature: mixing feature and non-feature specs 2 | Background: 3 | Given if pre-v10, additional Cypress configuration 4 | """ 5 | { 6 | "testFiles": "**/*.{spec.js,feature}" 7 | } 8 | """ 9 | And if post-v10, additional Cypress configuration 10 | """ 11 | { 12 | "e2e": { 13 | "specPattern": "**/*.{spec.js,feature}" 14 | } 15 | } 16 | """ 17 | 18 | Scenario: one feature and non-feature specs 19 | Given a file named "cypress/e2e/a.feature" with: 20 | """ 21 | @foo 22 | Feature: a feature name 23 | Scenario: a scenario name 24 | Given a step 25 | """ 26 | And a file named "cypress/support/step_definitions/steps.js" with: 27 | """ 28 | const { When, isFeature, doesFeatureMatch } = require("@klaveness/cypress-cucumber-preprocessor"); 29 | When("a step", function() { 30 | expect(isFeature()).to.be.true; 31 | expect(doesFeatureMatch("@foo")).to.be.true; 32 | }); 33 | """ 34 | And a file named "cypress/e2e/b.spec.js" with: 35 | """ 36 | const { isFeature } = require("@klaveness/cypress-cucumber-preprocessor"); 37 | it("should work", () => { 38 | expect(isFeature()).to.be.false; 39 | }); 40 | """ 41 | When I run cypress 42 | Then it passes 43 | And it should appear to have ran spec "a.feature" and "b.spec.js" 44 | -------------------------------------------------------------------------------- /features/fixtures/retried.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "status": "failed", 18 | "error_message": "some error" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | } 24 | ], 25 | "type": "scenario" 26 | }, 27 | { 28 | "description": "", 29 | "id": "a-feature;a-scenario", 30 | "keyword": "Scenario", 31 | "line": 2, 32 | "name": "a scenario", 33 | "steps": [ 34 | { 35 | "keyword": "Given ", 36 | "line": 3, 37 | "name": "a step", 38 | "result": { 39 | "duration": 0, 40 | "status": "passed" 41 | }, 42 | "match": { 43 | "location": "not available:0" 44 | } 45 | } 46 | ], 47 | "type": "scenario" 48 | } 49 | ], 50 | "id": "a-feature", 51 | "keyword": "Feature", 52 | "line": 1, 53 | "name": "a feature", 54 | "uri": "cypress/e2e/a.feature" 55 | } 56 | ] 57 | -------------------------------------------------------------------------------- /features/fixtures/passed-outline.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario;;2", 8 | "keyword": "Scenario Outline", 9 | "line": 6, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | } 24 | ], 25 | "type": "scenario" 26 | }, 27 | { 28 | "description": "", 29 | "id": "a-feature;a-scenario;;3", 30 | "keyword": "Scenario Outline", 31 | "line": 7, 32 | "name": "a scenario", 33 | "steps": [ 34 | { 35 | "keyword": "Given ", 36 | "line": 3, 37 | "name": "a step", 38 | "result": { 39 | "duration": 0, 40 | "status": "passed" 41 | }, 42 | "match": { 43 | "location": "not available:0" 44 | } 45 | } 46 | ], 47 | "type": "scenario" 48 | } 49 | ], 50 | "id": "a-feature", 51 | "keyword": "Feature", 52 | "line": 1, 53 | "name": "a feature", 54 | "uri": "cypress/e2e/a.feature" 55 | } 56 | ] 57 | -------------------------------------------------------------------------------- /features/fixtures/pending-steps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a pending step", 16 | "result": { 17 | "duration": 0, 18 | "status": "pending" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | }, 24 | { 25 | "keyword": "And ", 26 | "line": 4, 27 | "name": "another pending step", 28 | "result": { 29 | "status": "skipped" 30 | }, 31 | "match": { 32 | "location": "not available:0" 33 | } 34 | }, 35 | { 36 | "keyword": "And ", 37 | "line": 5, 38 | "name": "an implemented step", 39 | "result": { 40 | "status": "skipped" 41 | }, 42 | "match": { 43 | "location": "not available:0" 44 | } 45 | } 46 | ], 47 | "type": "scenario" 48 | } 49 | ], 50 | "id": "a-feature", 51 | "keyword": "Feature", 52 | "line": 1, 53 | "name": "a feature", 54 | "uri": "cypress/e2e/a.feature" 55 | } 56 | ] 57 | -------------------------------------------------------------------------------- /examples/webpack-ts/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import * as webpack from "@cypress/webpack-preprocessor"; 3 | import { addCucumberPreprocessorPlugin } from "@klaveness/cypress-cucumber-preprocessor"; 4 | 5 | async function setupNodeEvents( 6 | on: Cypress.PluginEvents, 7 | config: Cypress.PluginConfigOptions 8 | ): Promise { 9 | await addCucumberPreprocessorPlugin(on, config); 10 | 11 | on( 12 | "file:preprocessor", 13 | webpack({ 14 | webpackOptions: { 15 | resolve: { 16 | extensions: [".ts", ".js"], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | exclude: [/node_modules/], 23 | use: [ 24 | { 25 | loader: "ts-loader", 26 | }, 27 | ], 28 | }, 29 | { 30 | test: /\.feature$/, 31 | use: [ 32 | { 33 | loader: "@klaveness/cypress-cucumber-preprocessor/webpack", 34 | options: config, 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | }, 41 | }) 42 | ); 43 | 44 | // Make sure to return the config object as it might have been modified by the plugin. 45 | return config; 46 | } 47 | 48 | export default defineConfig({ 49 | e2e: { 50 | specPattern: "**/*.feature", 51 | supportFile: false, 52 | setupNodeEvents, 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /features/tags/tagged_hooks.feature: -------------------------------------------------------------------------------- 1 | Feature: tagged Hooks 2 | 3 | Background: 4 | Given a file named "cypress/support/step_definitions/steps.js" with: 5 | """ 6 | const { Then } = require("@klaveness/cypress-cucumber-preprocessor"); 7 | Then("{word} is true", function(prop) { 8 | expect(true).to.equal(this[prop]) 9 | }) 10 | Then("{word} is false", function(prop) { 11 | expect(false).to.equal(this[prop]) 12 | }) 13 | """ 14 | And a file named "cypress/support/step_definitions/hooks.js" with: 15 | """ 16 | const { 17 | Before 18 | } = require("@klaveness/cypress-cucumber-preprocessor"); 19 | Before(function() { 20 | this.foo = false 21 | this.bar = false 22 | }) 23 | Before({ tags: "@foo" }, function() { 24 | this.foo = true 25 | }) 26 | Before({ tags: "@bar" }, function() { 27 | this.bar = true 28 | }) 29 | """ 30 | 31 | Scenario: hooks filtered by tags on scenario 32 | Given a file named "cypress/e2e/a.feature" with: 33 | """ 34 | Feature: 35 | @foo 36 | Scenario: 37 | Then foo is true 38 | And bar is false 39 | """ 40 | When I run cypress 41 | Then it passes 42 | 43 | Scenario: tags cascade from feature to scenario 44 | Given a file named "cypress/e2e/a.feature" with: 45 | """ 46 | @foo 47 | Feature: 48 | Scenario: 49 | Then foo is true 50 | And bar is false 51 | """ 52 | When I run cypress 53 | Then it passes 54 | -------------------------------------------------------------------------------- /features/fixtures/multiple-features.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "", 4 | "elements": [ 5 | { 6 | "description": "", 7 | "id": "a-feature;a-scenario", 8 | "keyword": "Scenario", 9 | "line": 2, 10 | "name": "a scenario", 11 | "steps": [ 12 | { 13 | "keyword": "Given ", 14 | "line": 3, 15 | "name": "a step", 16 | "result": { 17 | "duration": 0, 18 | "status": "passed" 19 | }, 20 | "match": { 21 | "location": "not available:0" 22 | } 23 | } 24 | ], 25 | "type": "scenario" 26 | } 27 | ], 28 | "id": "a-feature", 29 | "keyword": "Feature", 30 | "line": 1, 31 | "name": "a feature", 32 | "uri": "cypress/e2e/a.feature" 33 | }, 34 | { 35 | "description": "", 36 | "elements": [ 37 | { 38 | "description": "", 39 | "id": "another-feature;another-scenario", 40 | "keyword": "Scenario", 41 | "line": 2, 42 | "name": "another scenario", 43 | "steps": [ 44 | { 45 | "keyword": "Given ", 46 | "line": 3, 47 | "name": "a step", 48 | "result": { 49 | "duration": 0, 50 | "status": "passed" 51 | }, 52 | "match": { 53 | "location": "not available:0" 54 | } 55 | } 56 | ], 57 | "type": "scenario" 58 | } 59 | ], 60 | "id": "another-feature", 61 | "keyword": "Feature", 62 | "line": 1, 63 | "name": "another feature", 64 | "uri": "cypress/e2e/b.feature" 65 | } 66 | ] 67 | -------------------------------------------------------------------------------- /features/step_definitions.feature: -------------------------------------------------------------------------------- 1 | Feature: step definitions 2 | 3 | Rule: it should by default look for step definitions in a couple of locations 4 | 5 | Example: step definitions with same filename 6 | Given a file named "cypress/e2e/a.feature" with: 7 | """ 8 | Feature: a feature name 9 | Scenario: a scenario name 10 | Given a step 11 | """ 12 | And a file named "cypress/e2e/a.js" with: 13 | """ 14 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 15 | Given("a step", function() {}); 16 | """ 17 | When I run cypress 18 | Then it passes 19 | 20 | Example: step definitions in a directory with same name 21 | Given a file named "cypress/e2e/a.feature" with: 22 | """ 23 | Feature: a feature name 24 | Scenario: a scenario name 25 | Given a step 26 | """ 27 | And a file named "cypress/e2e/a/steps.js" with: 28 | """ 29 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 30 | Given("a step", function() {}); 31 | """ 32 | When I run cypress 33 | Then it passes 34 | 35 | Example: step definitions in a common directory 36 | Given a file named "cypress/e2e/a.feature" with: 37 | """ 38 | Feature: a feature name 39 | Scenario: a scenario name 40 | Given a step 41 | """ 42 | And a file named "cypress/support/step_definitions/steps.js" with: 43 | """ 44 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 45 | Given("a step", function() {}); 46 | """ 47 | When I run cypress 48 | Then it passes 49 | -------------------------------------------------------------------------------- /features/tags/test_filter.feature: -------------------------------------------------------------------------------- 1 | Feature: test filter 2 | 3 | Scenario: with omitFiltered = false (default) 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: some feature 7 | Rule: first rule 8 | @foo 9 | Scenario: first scenario 10 | Given a step 11 | 12 | Rule: second rule 13 | Scenario: second scenario 14 | Given a step 15 | """ 16 | And a file named "cypress/support/step_definitions/steps.js" with: 17 | """ 18 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 19 | Given("a step", function() {}) 20 | """ 21 | When I run cypress with "--env tags=@foo" 22 | Then it passes 23 | And it should appear to have skipped the scenario "second scenario" 24 | 25 | Scenario: with omitFiltered = true 26 | Given additional preprocessor configuration 27 | """ 28 | { 29 | "omitFiltered": true 30 | } 31 | """ 32 | And a file named "cypress/e2e/a.feature" with: 33 | """ 34 | Feature: some feature 35 | Rule: first rule 36 | @foo 37 | Scenario: first scenario 38 | Given a step 39 | 40 | Rule: second rule 41 | Scenario: second scenario 42 | Given a step 43 | """ 44 | And a file named "cypress/support/step_definitions/steps.js" with: 45 | """ 46 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 47 | Given("a step", function() {}) 48 | """ 49 | When I run cypress with "--env tags=@foo" 50 | Then it passes 51 | And it should appear as if only a single test ran 52 | And I should not see "second rule" in the output 53 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | See [badeball/cypress-cucumber-preprocessor](https://github.com/badeball/cypress-cucumber-preprocessor) for a public, community-maintained edition and installation instructions. 4 | 5 | # Configuration 6 | 7 | [Configure](https://docs.cypress.io/guides/references/configuration) `specPattern` with `"**/*.feature"`, using EG. `cypress.config.ts`. 8 | 9 | ```js 10 | import { defineConfig } from "cypress"; 11 | 12 | export default defineConfig({ 13 | e2e: { 14 | specPattern: "**/*.feature" 15 | } 16 | }); 17 | ``` 18 | 19 | Configure your preferred bundler to process features files, with examples for 20 | 21 | * [Browserify](../examples/browserify) 22 | * [Webpack](../examples/webpack) 23 | * [Esbuild](../examples/esbuild) 24 | 25 | Read more about configuration options at [docs/configuration.md](configuration.md). 26 | 27 | # Write a test 28 | 29 | Write Gherkin documents and add a file for type definitions with a corresponding name (read more about how step definitions are resolved in [docs/step-definitions.md](step-definitions.md)). Reading [docs/cucumber-basics.md](cucumber-basics.md) is highly recommended. 30 | 31 | ```cucumber 32 | # cypress/e2e/duckduckgo.feature 33 | Feature: duckduckgo.com 34 | Scenario: visting the frontpage 35 | When I visit duckduckgo.com 36 | Then I should see a search bar 37 | ``` 38 | 39 | ```ts 40 | // cypress/e2e/duckduckgo.ts 41 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 42 | 43 | When("I visit duckduckgo.com", () => { 44 | cy.visit("https://www.duckduckgo.com"); 45 | }); 46 | 47 | Then("I should see a search bar", () => { 48 | cy.get("input").should( 49 | "have.attr", 50 | "placeholder", 51 | "Search the web without being tracked" 52 | ); 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /features/loaders/webpack.feature: -------------------------------------------------------------------------------- 1 | @no-default-plugin 2 | Feature: webpack + typescript 3 | Scenario: 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature name 7 | Scenario: a scenario name 8 | Given a step 9 | """ 10 | And a file named "cypress/plugins/index.js" or "setupNodeEvents.js" (depending on Cypress era) with: 11 | """ 12 | const webpack = require("@cypress/webpack-preprocessor"); 13 | 14 | module.exports = async (on, config) => { 15 | on( 16 | "file:preprocessor", 17 | webpack({ 18 | webpackOptions: { 19 | resolve: { 20 | extensions: [".ts", ".js"] 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts$/, 26 | exclude: [/node_modules/], 27 | use: [ 28 | { 29 | loader: "ts-loader" 30 | } 31 | ] 32 | }, 33 | { 34 | test: /\.feature$/, 35 | use: [ 36 | { 37 | loader: "@klaveness/cypress-cucumber-preprocessor/webpack", 38 | options: config 39 | } 40 | ] 41 | } 42 | ] 43 | } 44 | } 45 | }) 46 | ); 47 | }; 48 | """ 49 | And a file named "cypress/support/step_definitions/steps.ts" with: 50 | """ 51 | import { Given } from "@klaveness/cypress-cucumber-preprocessor"; 52 | Given("a step", function(this: Mocha.Context) {}); 53 | """ 54 | When I run cypress 55 | Then it passes 56 | -------------------------------------------------------------------------------- /features/doc_string.feature: -------------------------------------------------------------------------------- 1 | Feature: doc string 2 | 3 | Scenario: as only step definition argument 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature 7 | Scenario: a scenario 8 | Given a doc string step 9 | \"\"\" 10 | The cucumber (Cucumis sativus) is a widely cultivated plant in the gourd family Cucurbitaceae. 11 | \"\"\" 12 | """ 13 | And a file named "cypress/support/step_definitions/steps.js" with: 14 | """ 15 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 16 | Given("a doc string step", function(docString) { 17 | expect(docString).to.equal("The cucumber (Cucumis sativus) is a widely " + 18 | "cultivated plant in the gourd family Cucurbitaceae.") 19 | }) 20 | """ 21 | When I run cypress 22 | Then it passes 23 | 24 | Scenario: with other step definition arguments 25 | Given a file named "cypress/e2e/a.feature" with: 26 | """ 27 | Feature: a feature 28 | Scenario: a scenario 29 | Given a "doc string" step 30 | \"\"\" 31 | The cucumber (Cucumis sativus) is a widely cultivated plant in the gourd family Cucurbitaceae. 32 | \"\"\" 33 | """ 34 | And a file named "cypress/support/step_definitions/steps.js" with: 35 | """ 36 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 37 | Given("a {string} step", function(type, docString) { 38 | expect(type).to.equal("doc string") 39 | expect(docString).to.equal("The cucumber (Cucumis sativus) is a widely " + 40 | "cultivated plant in the gourd family Cucurbitaceae.") 41 | }) 42 | """ 43 | When I run cypress 44 | Then it passes 45 | -------------------------------------------------------------------------------- /docs/state-management.md: -------------------------------------------------------------------------------- 1 | # State management 2 | 3 | A step definition can transfer state to a subsequent step definition by storing state in instance variables, as shown below. 4 | 5 | ```ts 6 | import { Given } from "@klaveness/cypress-cucumber-preprocessor"; 7 | 8 | Given("a step asynchronously assigning to World", function() { 9 | cy.then(() => { 10 | this.foo = "bar"; 11 | }); 12 | }); 13 | 14 | Given("a step accessing said assignment synchronously", function() { 15 | expect(this.foo).to.equal("bar"); 16 | }); 17 | ``` 18 | 19 | Please note that if you use arrow functions, you won’t be able to share state between steps! 20 | 21 | ## Replicating `setWorldConstructor` 22 | 23 | Even though `setWorldConstructor` isn't implemented, it's behavior can be closely replicated like shown below. 24 | 25 | ```gherkin 26 | # cypress/e2e/math.feature 27 | Feature: Replicating setWorldConstructor() 28 | Scenario: easy maths 29 | Given a variable set to 1 30 | When I increment the variable by 1 31 | Then the variable should contain 2 32 | ``` 33 | 34 | ```ts 35 | // cypress/support/e2e.ts 36 | beforeEach(function () { 37 | const world = { 38 | variable: 0, 39 | 40 | setTo(number) { 41 | this.variable = number; 42 | }, 43 | 44 | incrementBy(number) { 45 | this.variable += number; 46 | } 47 | }; 48 | 49 | Object.assign(this, world); 50 | }); 51 | ``` 52 | 53 | ```ts 54 | // cypress/support/step_definitions/steps.js 55 | import { Given, When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 56 | 57 | Given("a variable set to {int}", function(number) { 58 | this.setTo(number); 59 | }); 60 | 61 | When("I increment the variable by {int}", function(number) { 62 | this.incrementBy(number); 63 | }); 64 | 65 | Then("the variable should contain {int}", function(number) { 66 | expect(this.variable).to.equal(number); 67 | }); 68 | ```` 69 | -------------------------------------------------------------------------------- /features/issues/705.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/badeball/cypress-cucumber-preprocessor/issues/705 2 | 3 | @no-default-plugin 4 | Feature: overriding event handlers 5 | Background: 6 | Given additional preprocessor configuration 7 | """ 8 | { 9 | "json": { 10 | "enabled": true 11 | } 12 | } 13 | """ 14 | 15 | Scenario: overriding after:screenshot 16 | Given a file named "cypress/e2e/a.feature" with: 17 | """ 18 | Feature: a feature 19 | Scenario: a scenario 20 | Given a failing step 21 | """ 22 | And a file named "cypress/support/step_definitions/steps.js" with: 23 | """ 24 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 25 | Given("a failing step", function() { 26 | throw "some error" 27 | }) 28 | """ 29 | And a file named "cypress/plugins/index.js" or "setupNodeEvents.js" (depending on Cypress era) with: 30 | """ 31 | const { addCucumberPreprocessorPlugin, afterScreenshotHandler } = require("@klaveness/cypress-cucumber-preprocessor"); 32 | const { createEsbuildPlugin } = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 33 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 34 | 35 | module.exports = async (on, config) => { 36 | await addCucumberPreprocessorPlugin(on, config, { omitAfterScreenshotHandler: true }); 37 | 38 | on("after:screenshot", (details) => afterScreenshotHandler(config, details)) 39 | 40 | on( 41 | "file:preprocessor", 42 | createBundler({ 43 | plugins: [createEsbuildPlugin(config)] 44 | }) 45 | ); 46 | 47 | return config; 48 | } 49 | """ 50 | 51 | When I run cypress 52 | Then it fails 53 | And the JSON report should contain an image attachment for what appears to be a screenshot 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-cucumber-preprocessor 2 | 3 | This preprocessor aims to provide a developer experience and behavior similar to that of [Cucumber](https://cucumber.io/), to Cypress. 4 | 5 | ## Installation 6 | 7 | See [badeball/cypress-cucumber-preprocessor](https://github.com/badeball/cypress-cucumber-preprocessor) for a public, community-maintained edition and installation instructions. 8 | 9 | ## Introduction 10 | 11 | The preprocessor (with its dependencies) parses Gherkin documents and allows you to write tests as shown below. 12 | 13 | ```cucumber 14 | # cypress/e2e/duckduckgo.feature 15 | Feature: duckduckgo.com 16 | Scenario: visting the frontpage 17 | When I visit duckduckgo.com 18 | Then I should see a search bar 19 | ``` 20 | 21 | ```ts 22 | // cypress/e2e/duckduckgo.ts 23 | import { When, Then } from "@klaveness/cypress-cucumber-preprocessor"; 24 | 25 | When("I visit duckduckgo.com", () => { 26 | cy.visit("https://www.duckduckgo.com"); 27 | }); 28 | 29 | Then("I should see a search bar", () => { 30 | cy.get("input").should( 31 | "have.attr", 32 | "placeholder", 33 | "Search the web without being tracked" 34 | ); 35 | }); 36 | ``` 37 | 38 | ## User guide 39 | 40 | For further documentation see [docs](docs/readme.md) and [docs/quick-start.md](docs/quick-start.md). 41 | 42 | ## Contributing 43 | 44 | See [badeball/cypress-cucumber-preprocessor](https://github.com/badeball/cypress-cucumber-preprocessor) for a public, community-maintained edition and contribution instructions. 45 | 46 | ## Building 47 | 48 | Building can be done once using: 49 | 50 | ``` 51 | $ npm run build 52 | ``` 53 | 54 | Or upon file changes with: 55 | 56 | ``` 57 | $ npm run watch 58 | ``` 59 | 60 | There are multiple types of tests, all ran using npm scripts: 61 | 62 | ``` 63 | $ npm run test:fmt 64 | $ npm run test:types 65 | $ npm run test:unit 66 | $ npm run test:integration # make sure to build first 67 | $ npm run test # runs all of the above 68 | ``` 69 | -------------------------------------------------------------------------------- /features/hooks_ordering.feature: -------------------------------------------------------------------------------- 1 | Feature: hooks ordering 2 | 3 | Hooks should be executed in the following order: 4 | - before 5 | - beforeEach 6 | - Before 7 | - Background steps 8 | - Ordinary steps 9 | - After 10 | - afterEach 11 | - after 12 | 13 | Scenario: with all hooks incrementing a counter 14 | Given a file named "cypress/e2e/a.feature" with: 15 | """ 16 | Feature: a feature 17 | Background: 18 | Given a background step 19 | Scenario: a scenario 20 | Given an ordinary step 21 | """ 22 | And a file named "cypress/support/step_definitions/steps.js" with: 23 | """ 24 | const { 25 | Given, 26 | Before, 27 | After 28 | } = require("@klaveness/cypress-cucumber-preprocessor") 29 | let counter; 30 | before(function() { 31 | counter = 0; 32 | }) 33 | beforeEach(function() { 34 | expect(counter++, "Expected beforeEach() to be called after before()").to.equal(0) 35 | }) 36 | Before(function() { 37 | expect(counter++, "Expected Before() to be called after beforeEach()").to.equal(1) 38 | }) 39 | Given("a background step", function() { 40 | expect(counter++, "Expected a background step to be called after Before()").to.equal(2) 41 | }) 42 | Given("an ordinary step", function() { 43 | expect(counter++, "Expected an ordinary step to be called after a background step").to.equal(3) 44 | }) 45 | After(function() { 46 | expect(counter++, "Expected After() to be called after ordinary steps").to.equal(4) 47 | }) 48 | afterEach(function() { 49 | expect(counter++, "Expected afterEach() to be called after After()").to.equal(5) 50 | }) 51 | after(function() { 52 | expect(counter++, "Expected after() to be called after afterEach()").to.equal(6) 53 | }) 54 | """ 55 | When I run cypress 56 | Then it passes 57 | -------------------------------------------------------------------------------- /features/tags/target_specific_scenarios_by_tag.feature: -------------------------------------------------------------------------------- 1 | Feature: target specific scenario 2 | 3 | Background: 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: some feature 7 | @a 8 | Scenario: first scenario 9 | Given a step 10 | @b 11 | Scenario Outline: second scenario - 12 | Given a step 13 | @c 14 | Examples: 15 | | ID | 16 | | X | 17 | | Y | 18 | @d 19 | Examples: 20 | | ID | 21 | | Z | 22 | """ 23 | And a file named "cypress/support/step_definitions/steps.js" with: 24 | """ 25 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 26 | Given("a step", function() {}) 27 | """ 28 | 29 | Scenario: run a single scenario 30 | When I run cypress with "--env tags=@a" 31 | Then it passes 32 | And it should appear to have run the scenario "first scenario" 33 | 34 | Scenario: filter out scenarios with ~ 35 | When I run cypress with "--env 'tags=not @b'" 36 | Then it passes 37 | And it should appear to have run the scenario "first scenario" 38 | 39 | Scenario: run a single scenario outline 40 | When I run cypress with "--env tags=@b" 41 | Then it passes 42 | And it should appear to have run the scenarios 43 | | Name | 44 | | second scenario - X (example #1) | 45 | | second scenario - Y (example #2) | 46 | | second scenario - Z (example #3) | 47 | 48 | Scenario: run a single scenario outline examples 49 | When I run cypress with "--env tags=@d" 50 | Then it passes 51 | And it should appear to have run the scenario "second scenario - Z (example #3)" 52 | But it should appear to not have run the scenarios 53 | | Name | 54 | | second scenario - X (example #1) | 55 | | second scenario - Y (example #2) | 56 | -------------------------------------------------------------------------------- /features/world_example.feature: -------------------------------------------------------------------------------- 1 | Feature: world 2 | 3 | Scenario: example of an World 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: Simple maths 7 | In order to do maths 8 | As a developer 9 | I want to increment variables 10 | 11 | Scenario: easy maths 12 | Given a variable set to 1 13 | When I increment the variable by 1 14 | Then the variable should contain 2 15 | 16 | Scenario Outline: much more complex stuff 17 | Given a variable set to 18 | When I increment the variable by 19 | Then the variable should contain 20 | 21 | Examples: 22 | | var | increment | result | 23 | | 100 | 5 | 105 | 24 | | 99 | 1234 | 1333 | 25 | | 12 | 5 | 17 | 26 | """ 27 | And a file named "cypress/support/e2e.js" with: 28 | """ 29 | beforeEach(function () { 30 | // This mimics setWorldConstructor of cucumber-js. 31 | const world = { 32 | variable: 0, 33 | 34 | setTo(number) { 35 | this.variable = number; 36 | }, 37 | 38 | incrementBy(number) { 39 | this.variable += number; 40 | } 41 | }; 42 | 43 | Object.assign(this, world); 44 | }); 45 | """ 46 | And a file named "cypress/support/step_definitions/steps.js" with: 47 | """ 48 | const { Given, When, Then } = require("@klaveness/cypress-cucumber-preprocessor"); 49 | 50 | Given("a variable set to {int}", function(number) { 51 | this.setTo(number); 52 | }); 53 | 54 | When("I increment the variable by {int}", function(number) { 55 | this.incrementBy(number); 56 | }); 57 | 58 | Then("the variable should contain {int}", function(number) { 59 | expect(this.variable).to.equal(number); 60 | }); 61 | """ 62 | When I run cypress 63 | Then it passes 64 | -------------------------------------------------------------------------------- /features/step_definitions/config_steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import { promises as fs } from "fs"; 4 | import { isPost10, isPre10 } from "../support/helpers"; 5 | import { insertValuesInConfigFile } from "../support/configFileUpdater"; 6 | 7 | async function updateJsonConfiguration( 8 | absoluteConfigPath: string, 9 | additionalJsonContent: any 10 | ) { 11 | const existingConfig = JSON.parse( 12 | (await fs.readFile(absoluteConfigPath)).toString() 13 | ); 14 | 15 | await fs.writeFile( 16 | absoluteConfigPath, 17 | JSON.stringify( 18 | { 19 | ...existingConfig, 20 | ...additionalJsonContent, 21 | }, 22 | null, 23 | 2 24 | ) 25 | ); 26 | } 27 | 28 | Given("additional preprocessor configuration", async function (jsonContent) { 29 | const absoluteConfigPath = path.join( 30 | this.tmpDir, 31 | ".cypress-cucumber-preprocessorrc" 32 | ); 33 | 34 | await updateJsonConfiguration(absoluteConfigPath, JSON.parse(jsonContent)); 35 | }); 36 | 37 | Given("additional Cypress configuration", async function (jsonContent) { 38 | if (isPost10()) { 39 | await insertValuesInConfigFile( 40 | path.join(this.tmpDir, "cypress.config.js"), 41 | JSON.parse(jsonContent) 42 | ); 43 | } else { 44 | await updateJsonConfiguration( 45 | path.join(this.tmpDir, "cypress.json"), 46 | JSON.parse(jsonContent) 47 | ); 48 | } 49 | }); 50 | 51 | Given( 52 | "if post-v10, additional Cypress configuration", 53 | async function (jsonContent) { 54 | if (isPost10()) { 55 | await insertValuesInConfigFile( 56 | path.join(this.tmpDir, "cypress.config.js"), 57 | JSON.parse(jsonContent) 58 | ); 59 | } 60 | } 61 | ); 62 | 63 | Given( 64 | "if pre-v10, additional Cypress configuration", 65 | async function (jsonContent) { 66 | if (isPre10()) { 67 | await updateJsonConfiguration( 68 | path.join(this.tmpDir, "cypress.json"), 69 | JSON.parse(jsonContent) 70 | ); 71 | } 72 | } 73 | ); 74 | -------------------------------------------------------------------------------- /features/scenario_outlines.feature: -------------------------------------------------------------------------------- 1 | Feature: scenario outlines and examples 2 | 3 | Scenario: placeholder in step 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature 7 | Scenario Outline: a scenario 8 | Given a step 9 | Examples: 10 | | value | 11 | | foo | 12 | """ 13 | And a file named "cypress/support/step_definitions/steps.js" with: 14 | """ 15 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 16 | Given("a foo step", function() {}) 17 | """ 18 | When I run cypress 19 | Then it passes 20 | 21 | Scenario: placeholder in docstring 22 | Given a file named "cypress/e2e/a.feature" with: 23 | """ 24 | Feature: a feature 25 | Scenario Outline: a scenario 26 | Given a doc string step 27 | \"\"\" 28 | a doc string 29 | \"\"\" 30 | Examples: 31 | | value | 32 | | foo | 33 | """ 34 | And a file named "cypress/support/step_definitions/steps.js" with: 35 | """ 36 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 37 | Given("a doc string step", function(docString) { 38 | expect(docString).to.equal("a foo doc string") 39 | }) 40 | """ 41 | When I run cypress 42 | Then it passes 43 | 44 | Scenario: placeholder in table 45 | Given a file named "cypress/e2e/a.feature" with: 46 | """ 47 | Feature: a feature 48 | Scenario Outline: a scenario 49 | Given a table step 50 | | | 51 | Examples: 52 | | value | 53 | | foo | 54 | """ 55 | And a file named "cypress/support/step_definitions/steps.js" with: 56 | """ 57 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 58 | Given("a table step", function(tableData) { 59 | expect(tableData.raw()[0][0]).to.equal("foo") 60 | }) 61 | """ 62 | When I run cypress 63 | Then it passes 64 | -------------------------------------------------------------------------------- /features/support/world.ts: -------------------------------------------------------------------------------- 1 | import { setWorldConstructor, IWorld } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import childProcess from "child_process"; 4 | import { PassThrough, Readable } from "stream"; 5 | import { WritableStreamBuffer } from "stream-buffers"; 6 | 7 | const projectPath = path.join(__dirname, "..", ".."); 8 | 9 | const isWin = process.platform === "win32"; 10 | 11 | function combine(...streams: Readable[]) { 12 | return streams.reduce((combined, stream) => { 13 | stream.pipe(combined, { end: false }); 14 | stream.once( 15 | "end", 16 | () => streams.every((s) => s.readableEnded) && combined.emit("end") 17 | ); 18 | return combined; 19 | }, new PassThrough()); 20 | } 21 | 22 | class World { 23 | async run(this: IWorld, extraArgs = [], extraEnv = {}) { 24 | const child = childProcess.spawn( 25 | path.join( 26 | projectPath, 27 | "node_modules", 28 | ".bin", 29 | isWin ? "cypress.cmd" : "cypress" 30 | ), 31 | ["run", ...extraArgs], 32 | { 33 | stdio: ["ignore", "pipe", "pipe"], 34 | cwd: this.tmpDir, 35 | env: { 36 | ...process.env, 37 | NO_COLOR: "1", 38 | ...extraEnv, 39 | }, 40 | } 41 | ); 42 | 43 | const combined = combine(child.stdout, child.stderr); 44 | 45 | if (process.env.DEBUG) { 46 | child.stdout.pipe(process.stdout); 47 | child.stderr.pipe(process.stderr); 48 | } 49 | 50 | const stdoutBuffer = child.stdout.pipe(new WritableStreamBuffer()); 51 | const stderrBuffer = child.stderr.pipe(new WritableStreamBuffer()); 52 | const outputBuffer = combined.pipe(new WritableStreamBuffer()); 53 | 54 | const exitCode = await new Promise((resolve) => { 55 | child.on("close", resolve); 56 | }); 57 | 58 | const stdout = stdoutBuffer.getContentsAsString() || ""; 59 | const stderr = stderrBuffer.getContentsAsString() || ""; 60 | const output = outputBuffer.getContentsAsString() || ""; 61 | 62 | this.verifiedLastRunError = false; 63 | 64 | this.lastRun = { 65 | stdout, 66 | stderr, 67 | output, 68 | exitCode, 69 | }; 70 | } 71 | } 72 | 73 | setWorldConstructor(World); 74 | -------------------------------------------------------------------------------- /lib/data_table.ts: -------------------------------------------------------------------------------- 1 | import { messages } from "@cucumber/messages"; 2 | 3 | import { assert, assertAndReturn } from "./assertions"; 4 | 5 | function zip(collectionA: A[], collectionB: B[]) { 6 | return collectionA.map<[A, B]>((element, index) => [ 7 | element, 8 | collectionB[index], 9 | ]); 10 | } 11 | 12 | export default class DataTable { 13 | private readonly rawTable: string[][]; 14 | 15 | constructor( 16 | sourceTable: messages.PickleStepArgument.IPickleTable | string[][] 17 | ) { 18 | if (sourceTable instanceof Array) { 19 | this.rawTable = sourceTable; 20 | } else { 21 | this.rawTable = assertAndReturn( 22 | sourceTable.rows, 23 | "Expected a PicleTable to have rows" 24 | ).map((row) => 25 | assertAndReturn( 26 | row.cells, 27 | "Expected a PicleTableRow to have cells" 28 | ).map((cell) => { 29 | const { value } = cell; 30 | assert(value != null, "Expected a PicleTableCell to have a value"); 31 | return value; 32 | }) 33 | ); 34 | } 35 | } 36 | 37 | hashes(): any[] { 38 | const copy = this.raw(); 39 | const keys = copy[0]; 40 | const valuesArray = copy.slice(1); 41 | return valuesArray.map((values) => Object.fromEntries(zip(keys, values))); 42 | } 43 | 44 | raw(): string[][] { 45 | return this.rawTable.slice(0); 46 | } 47 | 48 | rows(): string[][] { 49 | const copy = this.raw(); 50 | copy.shift(); 51 | return copy; 52 | } 53 | 54 | rowsHash() { 55 | return Object.fromEntries( 56 | this.raw().map<[string, string]>((values) => { 57 | const [first, second, ...rest] = values; 58 | 59 | if (first == null || second == null || rest.length !== 0) { 60 | throw new Error( 61 | "rowsHash can only be called on a data table where all rows have exactly two columns" 62 | ); 63 | } 64 | 65 | return [first, second]; 66 | }) 67 | ); 68 | } 69 | 70 | transpose() { 71 | const transposed = this.rawTable[0].map((x, i) => 72 | this.rawTable.map((y) => y[i]) 73 | ); 74 | 75 | return new DataTable(transposed); 76 | } 77 | 78 | toString() { 79 | return "[object DataTable]"; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /features/attachments.feature: -------------------------------------------------------------------------------- 1 | Feature: attachments 2 | 3 | Background: 4 | Given additional preprocessor configuration 5 | """ 6 | { 7 | "json": { 8 | "enabled": true 9 | } 10 | } 11 | """ 12 | And I've ensured cucumber-json-formatter is installed 13 | 14 | Scenario: string identity 15 | Given a file named "cypress/e2e/a.feature" with: 16 | """ 17 | Feature: a feature 18 | Scenario: a scenario 19 | Given a step 20 | """ 21 | And a file named "cypress/support/step_definitions/steps.js" with: 22 | """ 23 | const { Given, attach } = require("@klaveness/cypress-cucumber-preprocessor"); 24 | Given("a step", function() { 25 | attach("foobar") 26 | }) 27 | """ 28 | When I run cypress 29 | Then it passes 30 | And there should be a JSON output similar to "fixtures/attachments/string.json" 31 | 32 | Scenario: array buffer 33 | Given a file named "cypress/e2e/a.feature" with: 34 | """ 35 | Feature: a feature 36 | Scenario: a scenario 37 | Given a step 38 | """ 39 | And a file named "cypress/support/step_definitions/steps.js" with: 40 | """ 41 | const { Given, attach } = require("@klaveness/cypress-cucumber-preprocessor"); 42 | Given("a step", function() { 43 | attach(new TextEncoder().encode("foobar").buffer, "text/plain") 44 | }) 45 | """ 46 | When I run cypress 47 | Then it passes 48 | And there should be a JSON output similar to "fixtures/attachments/string.json" 49 | 50 | Scenario: string encoded 51 | Given a file named "cypress/e2e/a.feature" with: 52 | """ 53 | Feature: a feature 54 | Scenario: a scenario 55 | Given a step 56 | """ 57 | And a file named "cypress/support/step_definitions/steps.js" with: 58 | """ 59 | const { fromByteArray } = require("base64-js"); 60 | const { Given, attach } = require("@klaveness/cypress-cucumber-preprocessor"); 61 | Given("a step", function() { 62 | attach(fromByteArray(new TextEncoder().encode("foobar")), "base64:text/plain") 63 | }) 64 | """ 65 | When I run cypress 66 | Then it passes 67 | And there should be a JSON output similar to "fixtures/attachments/string.json" 68 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The preprocessor uses [cosmiconfig](https://github.com/davidtheclark/cosmiconfig), which means you can place configuration options in EG. `.cypress-cucumber-preprocessorrc.json` or `package.json`, with corresponding examples shown below. 4 | 5 | ``` 6 | // .cypress-cucumber-preprocessorrc.json 7 | { 8 | "json": { 9 | "enabled": true 10 | } 11 | } 12 | ``` 13 | 14 | ``` 15 | // package.json 16 | { 17 | "dependencies": { 18 | "@klaveness/cypress-cucumber-preprocessor": "latest" 19 | }, 20 | "cypress-cucumber-preprocessor": { 21 | "json": { 22 | "enabled": true 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | ## Configuration overrides 29 | 30 | Configuration options can be overriden using (Cypress-) [environment variable](https://docs.cypress.io/guides/guides/environment-variables). The `filterSpecs` options (described in [docs/tags.md](tags.md)) can for instance be overriden by running Cypress like shown below. 31 | 32 | ``` 33 | $ cypress run -e filterSpecs=true 34 | ``` 35 | 36 | Cypress environment variables can also be configured through ordinary environment variables, like shown below. 37 | 38 | ``` 39 | $ CYPRESS_filterSpecs=true cypress run 40 | ``` 41 | 42 | Every configuration option has a similar key which can be use to override it, shown in the table below. 43 | 44 | | JSON path | Environment key | Example(s) | 45 | |--------------------|-------------------|------------------------------------------| 46 | | `stepDefinitions` | `stepDefinitions` | `[filepath].{js,ts}` | 47 | | `messages.enabled` | `messagesEnabled` | `true`, `false` | 48 | | `messages.output` | `messagesOutput` | `cucumber-messages.ndjson` | 49 | | `json.enabled` | `jsonEnabled` | `true`, `false` | 50 | | `json.formatter` | `jsonFormatter` | `/usr/bin/cucumber-json-formatter` | 51 | | `json.output` | `jsonOutput` | `cucumber-report.json` | 52 | | `filterSpecs` | `filterSpecs` | `true`, `false` | 53 | | `omitFiltered` | `omitFiltered` | `true`, `false` | 54 | 55 | ## Test configuration 56 | 57 | Some of Cypress' [configuration options](https://docs.cypress.io/guides/references/configuration) can be overridden per-test, [Test configuration](test-configuration.md). 58 | -------------------------------------------------------------------------------- /lib/preprocessor-configuration.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | import { resolve } from "./preprocessor-configuration"; 4 | 5 | describe("resolve()", () => { 6 | it("overriding stepDefinitions", async () => { 7 | const { stepDefinitions } = await resolve( 8 | "/foo/bar", 9 | { stepDefinitions: "foo/bar/**" }, 10 | () => null 11 | ); 12 | 13 | assert.strictEqual(stepDefinitions, "foo/bar/**"); 14 | }); 15 | 16 | it("overriding messages.enabled (1)", async () => { 17 | const { 18 | messages: { enabled }, 19 | } = await resolve("/foo/bar", { messagesEnabled: "" }, () => ({ 20 | messages: { enabled: true }, 21 | })); 22 | 23 | assert.strictEqual(enabled, true); 24 | }); 25 | 26 | it("overriding messages.enabled (2)", async () => { 27 | const { 28 | messages: { enabled }, 29 | } = await resolve("/foo/bar", { messagesEnabled: "true" }, () => ({ 30 | messages: { enabled: false }, 31 | })); 32 | 33 | assert.strictEqual(enabled, true); 34 | }); 35 | 36 | it("overriding messages.enabled (3)", async () => { 37 | const { 38 | messages: { enabled }, 39 | } = await resolve("/foo/bar", { messagesEnabled: "foobar" }, () => ({ 40 | messages: { enabled: false }, 41 | })); 42 | 43 | assert.strictEqual(enabled, true); 44 | }); 45 | 46 | it("overriding messages.enabled (4)", async () => { 47 | const { 48 | messages: { enabled }, 49 | } = await resolve("/foo/bar", { messagesEnabled: true }, () => ({ 50 | messages: { enabled: false }, 51 | })); 52 | 53 | assert.strictEqual(enabled, true); 54 | }); 55 | 56 | it("overriding messages.enabled (5)", async () => { 57 | const { 58 | messages: { enabled }, 59 | } = await resolve("/foo/bar", { messagesEnabled: "false" }, () => ({ 60 | messages: { enabled: true }, 61 | })); 62 | 63 | assert.strictEqual(enabled, false); 64 | }); 65 | 66 | it("overriding messages.enabled (6)", async () => { 67 | const { 68 | messages: { enabled }, 69 | } = await resolve("/foo/bar", { messagesEnabled: "false" }, () => ({ 70 | messages: { enabled: true }, 71 | })); 72 | 73 | assert.strictEqual(enabled, false); 74 | }); 75 | 76 | it("overriding messages.enabled (7)", async () => { 77 | const { 78 | messages: { enabled }, 79 | } = await resolve("/foo/bar", { messagesEnabled: false }, () => ({ 80 | messages: { enabled: true }, 81 | })); 82 | 83 | assert.strictEqual(enabled, false); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /browserify.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough, Transform, TransformCallback } from "stream"; 2 | 3 | import { EventEmitter } from "events"; 4 | 5 | import browserify from "@cypress/browserify-preprocessor"; 6 | 7 | import { ICypressConfiguration } from "@klaveness/cypress-configuration"; 8 | 9 | import debug from "./lib/debug"; 10 | 11 | import { compile } from "./lib/template"; 12 | 13 | export function transform( 14 | configuration: ICypressConfiguration, 15 | filepath: string 16 | ) { 17 | if (!filepath.match(".feature$")) { 18 | return new PassThrough(); 19 | } 20 | 21 | debug(`compiling ${filepath}`); 22 | 23 | let buffer = Buffer.alloc(0); 24 | 25 | return new Transform({ 26 | transform(chunk: any, encoding: string, done: TransformCallback) { 27 | buffer = Buffer.concat([buffer, chunk]); 28 | done(); 29 | }, 30 | async flush(done: TransformCallback) { 31 | try { 32 | done( 33 | null, 34 | await compile(configuration, buffer.toString("utf8"), filepath) 35 | ); 36 | 37 | debug(`compiled ${filepath}`); 38 | } catch (e: any) { 39 | done(e); 40 | } 41 | }, 42 | }); 43 | } 44 | 45 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#File-object 46 | type ICypressPreprocessorFile = EventEmitter & { 47 | filePath: string; 48 | outputPath: string; 49 | shouldWatch: boolean; 50 | }; 51 | 52 | function preprendTransformerToOptions( 53 | configuration: ICypressConfiguration, 54 | options: any 55 | ) { 56 | let wrappedTransform; 57 | 58 | if ( 59 | !options.browserifyOptions || 60 | !Array.isArray(options.browserifyOptions.transform) 61 | ) { 62 | wrappedTransform = [transform.bind(null, configuration)]; 63 | } else { 64 | wrappedTransform = [ 65 | transform.bind(null, configuration), 66 | ...options.browserifyOptions.transform, 67 | ]; 68 | } 69 | 70 | return { 71 | ...options, 72 | browserifyOptions: { 73 | ...(options.browserifyOptions || {}), 74 | transform: wrappedTransform, 75 | }, 76 | }; 77 | } 78 | 79 | export function preprocessor( 80 | configuration: ICypressConfiguration, 81 | options = browserify.defaultOptions, 82 | { prependTransform = true }: { prependTransform?: boolean } = {} 83 | ) { 84 | if (prependTransform) { 85 | options = preprendTransformerToOptions(configuration, options); 86 | } 87 | 88 | return function (file: ICypressPreprocessorFile) { 89 | return browserify(options)(file); 90 | }; 91 | } 92 | 93 | export { ICypressConfiguration }; 94 | 95 | export { compile }; 96 | 97 | export default preprocessor; 98 | -------------------------------------------------------------------------------- /lib/template.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import { generateMessages } from "@cucumber/gherkin"; 4 | 5 | import { IdGenerator } from "@cucumber/messages"; 6 | 7 | import { ICypressConfiguration } from "@klaveness/cypress-configuration"; 8 | 9 | import { assertAndReturn } from "./assertions"; 10 | 11 | import { resolve } from "./preprocessor-configuration"; 12 | 13 | import { getStepDefinitionPaths } from "./step-definitions"; 14 | 15 | import { notNull } from "./type-guards"; 16 | 17 | const { stringify } = JSON; 18 | 19 | export async function compile( 20 | this: any, 21 | configuration: ICypressConfiguration, 22 | data: string, 23 | uri: string = this.resourcePath 24 | ) { 25 | const options = { 26 | includeSource: false, 27 | includeGherkinDocument: true, 28 | includePickles: true, 29 | newId: IdGenerator.uuid(), 30 | }; 31 | 32 | const relativeUri = path.relative(configuration.projectRoot, uri); 33 | 34 | const envelopes = generateMessages(data, relativeUri, options); 35 | 36 | if (envelopes[0].parseError) { 37 | throw new Error( 38 | assertAndReturn( 39 | envelopes[0].parseError.message, 40 | "Expected parse error to have a description" 41 | ) 42 | ); 43 | } 44 | 45 | const gherkinDocument = assertAndReturn( 46 | envelopes.map((envelope) => envelope.gherkinDocument).find(notNull), 47 | "Expected to find a gherkin document amongst the envelopes." 48 | ); 49 | 50 | const pickles = envelopes.map((envelope) => envelope.pickle).filter(notNull); 51 | 52 | const preprocessor = await resolve( 53 | configuration.projectRoot, 54 | configuration.env 55 | ); 56 | 57 | const stepDefinitions = await getStepDefinitionPaths( 58 | { 59 | cypress: configuration, 60 | preprocessor, 61 | }, 62 | uri 63 | ); 64 | 65 | const prepareLibPath = (...parts: string[]) => 66 | stringify(path.join(__dirname, ...parts)); 67 | 68 | const createTestsPath = prepareLibPath("create-tests"); 69 | 70 | const registryPath = prepareLibPath("registry"); 71 | 72 | return ` 73 | const { default: createTests } = require(${createTestsPath}); 74 | const { withRegistry } = require(${registryPath}); 75 | 76 | const registry = withRegistry(() => { 77 | ${stepDefinitions 78 | .map((stepDefintion) => `require(${stringify(stepDefintion)});`) 79 | .join("\n ")} 80 | }); 81 | 82 | registry.finalize(); 83 | 84 | createTests( 85 | registry, 86 | ${stringify(data)}, 87 | ${stringify(gherkinDocument)}, 88 | ${stringify(pickles)}, 89 | ${preprocessor.messages.enabled}, 90 | ${preprocessor.omitFiltered} 91 | ); 92 | `; 93 | } 94 | -------------------------------------------------------------------------------- /types/methods.ts: -------------------------------------------------------------------------------- 1 | import DataTable from "../lib/data_table"; 2 | 3 | import { 4 | Given, 5 | When, 6 | Then, 7 | Step, 8 | defineParameterType, 9 | Before, 10 | After, 11 | } from "../methods"; 12 | 13 | Given("foo", function (foo, bar: number, baz: string) { 14 | this; // $ExpectType Context 15 | foo; // $ExpectType unknown 16 | bar; // $ExpectType number 17 | baz; // $ExpectType string 18 | }); 19 | 20 | Given(/foo/, function (foo, bar: number, baz: string) { 21 | this; // $ExpectType Context 22 | foo; // $ExpectType unknown 23 | bar; // $ExpectType number 24 | baz; // $ExpectType string 25 | }); 26 | 27 | When("foo", function (foo, bar: number, baz: string) { 28 | this; // $ExpectType Context 29 | foo; // $ExpectType unknown 30 | bar; // $ExpectType number 31 | baz; // $ExpectType string 32 | }); 33 | 34 | When(/foo/, function (foo, bar: number, baz: string) { 35 | this; // $ExpectType Context 36 | foo; // $ExpectType unknown 37 | bar; // $ExpectType number 38 | baz; // $ExpectType string 39 | }); 40 | 41 | Then("foo", function (foo, bar: number, baz: string) { 42 | this; // $ExpectType Context 43 | foo; // $ExpectType unknown 44 | bar; // $ExpectType number 45 | baz; // $ExpectType string 46 | }); 47 | 48 | Then(/foo/, function (foo, bar: number, baz: string) { 49 | this; // $ExpectType Context 50 | foo; // $ExpectType unknown 51 | bar; // $ExpectType number 52 | baz; // $ExpectType string 53 | }); 54 | 55 | declare const table: DataTable; 56 | 57 | Then("foo", function () { 58 | // Step should consume Mocha.Context. 59 | Step(this, "foo"); 60 | }); 61 | 62 | Then("foo", function () { 63 | // Step should consume DataTable's. 64 | Step(this, "foo", table); 65 | }); 66 | 67 | Then("foo", function () { 68 | // Step should consume doc strings. 69 | Step(this, "foo", "bar"); 70 | }); 71 | 72 | defineParameterType({ 73 | name: "foo", 74 | regexp: /foo/, 75 | transformer(foo, bar, baz) { 76 | this; // $ExpectType Context 77 | foo; // $ExpectType string 78 | bar; // $ExpectType string 79 | baz; // $ExpectType string 80 | }, 81 | }); 82 | 83 | Before(function () { 84 | this; // $ExpectType Context 85 | }); 86 | 87 | Before({}, function () { 88 | this; // $ExpectType Context 89 | }); 90 | 91 | Before({ tags: "foo" }, function () { 92 | this; // $ExpectType Context 93 | }); 94 | 95 | After(function () { 96 | this; // $ExpectType Context 97 | }); 98 | 99 | After({}, function () { 100 | this; // $ExpectType Context 101 | }); 102 | 103 | After({ tags: "foo" }, function () { 104 | this; // $ExpectType Context 105 | }); 106 | -------------------------------------------------------------------------------- /docs/tags.md: -------------------------------------------------------------------------------- 1 | # Tags 2 | 3 | Tests can be filtered using the (Cypress-) [environment variable](https://docs.cypress.io/guides/guides/environment-variables) `tags` or `TAGS`. Note that the term "environment variable" here does **not** refer to OS-level environment variables. 4 | 5 | A feature or scenario can have as many tags as you like, separated by spaces. Tags can be placed above the following Gherkin elements. 6 | 7 | * `Feature` 8 | * `scenario` 9 | * `Scenario Outline` 10 | * `Examples` 11 | 12 | In `Scenario Outline`, you can use tags on different example like below. 13 | 14 | ```cucumber 15 | Scenario Outline: Steps will run conditionally if tagged 16 | Given user is logged in 17 | When user clicks 18 | Then user will be logged out 19 | 20 | @mobile 21 | Examples: 22 | | link | 23 | | logout link on mobile | 24 | 25 | @desktop 26 | Examples: 27 | | link | 28 | | logout link on desktop | 29 | ``` 30 | 31 | It is not possible to place tags above `Background` or steps (`Given`, `When`, `Then`, `And` and `But`). 32 | 33 | ## Tag inheritance 34 | 35 | Tags are inherited by child elements. Tags that are placed above a `Feature` will be inherited by `Scenario`, `Scenario Outline`, or `Examples`. Tags that are placed above a `Scenario Outline` will be inherited by `Examples`. 36 | 37 | ## Running a subset of scenarios 38 | 39 | Normally when running a subset of scenarios using `cypress run --env tags=@foo`, you could potentially encounter files containing no matching scenarios. These can be pre-filtered away by setting `filterSpecs` to `true`, thus saving you execution time. This **requires** you to have registered this module in your [plugin file](https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Plugins-file), as shown below. 40 | 41 | ```ts 42 | import { addCucumberPreprocessorPlugin } from "@klaveness/cypress-cucumber-preprocessor"; 43 | 44 | export default async ( 45 | on: Cypress.PluginEvents, 46 | config: Cypress.PluginConfigOptions 47 | ): Promise => { 48 | await addCucumberPreprocessorPlugin(on, config); 49 | 50 | // Make sure to return the config object as it might have been modified by the plugin. 51 | return config; 52 | } 53 | ``` 54 | 55 | ## Omit filtered tests 56 | 57 | By default, all filtered tests are made *pending* using `it.skip` method. If you want to completely omit them, set `omitFiltered` to `true`. 58 | 59 | ## Smart tagging 60 | 61 | In the absence of a `tags` value and presence of a scenario with `@only`, only said scenario will run. You can in other words use this tag as you would use `.only()` in Mocha. 62 | 63 | Similarly, scenarios tagged with `@skip` will always be skipped, despite being tagged with something else matching a tag filter. 64 | -------------------------------------------------------------------------------- /lib/tag-parser/tokenizer.ts: -------------------------------------------------------------------------------- 1 | import { TagTokenizerError } from "./errors"; 2 | 3 | export const isAt = (char: string): boolean => char === "@"; 4 | export const isOpeningParanthesis = (char: string): boolean => char === "("; 5 | export const isClosingParanthesis = (char: string): boolean => char === ")"; 6 | export const isWordChar = (char: string): boolean => /[a-zA-Z]/.test(char); 7 | export const isQuote = (char: string): boolean => char === '"' || char === "'"; 8 | export const isDigit = (char: string): boolean => /[0-9]/.test(char); 9 | export const isComma = (char: string): boolean => char === ","; 10 | export const isEqual = (char: string): boolean => char === "="; 11 | 12 | export class Tokenizer { 13 | public constructor(private content: string) {} 14 | 15 | public *tokens(): Generator< 16 | { 17 | value: string; 18 | position: number; 19 | }, 20 | void, 21 | unknown 22 | > { 23 | let position = 0; 24 | 25 | while (position < this.content.length) { 26 | const curchar = this.content[position]; 27 | 28 | if ( 29 | isAt(curchar) || 30 | isOpeningParanthesis(curchar) || 31 | isClosingParanthesis(curchar) || 32 | isComma(curchar) || 33 | isEqual(curchar) 34 | ) { 35 | yield { 36 | value: curchar, 37 | position, 38 | }; 39 | 40 | position++; 41 | } else if (isDigit(curchar)) { 42 | const start = position; 43 | 44 | while ( 45 | isDigit(this.content[position]) && 46 | position < this.content.length 47 | ) { 48 | position++; 49 | } 50 | 51 | yield { 52 | value: this.content.slice(start, position), 53 | position: start, 54 | }; 55 | } else if (isWordChar(curchar)) { 56 | const start = position; 57 | 58 | while ( 59 | isWordChar(this.content[position]) && 60 | position < this.content.length 61 | ) { 62 | position++; 63 | } 64 | 65 | yield { 66 | value: this.content.slice(start, position), 67 | position: start, 68 | }; 69 | } else if (isQuote(curchar)) { 70 | const start = position++; 71 | 72 | while ( 73 | !isQuote(this.content[position]) && 74 | position < this.content.length 75 | ) { 76 | position++; 77 | } 78 | 79 | if (position === this.content.length) { 80 | throw new TagTokenizerError("Unexpected end-of-string"); 81 | } else { 82 | position++; 83 | } 84 | 85 | yield { 86 | value: this.content.slice(start, position), 87 | position: start, 88 | }; 89 | } else { 90 | throw new TagTokenizerError(`Unknown token at ${position}: ${curchar}`); 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/tag-parser.test.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | 3 | import assert from "assert"; 4 | 5 | import { tagToCypressOptions } from "./tag-parser"; 6 | 7 | function example(tag: string, expectedOptions: any) { 8 | it(`should return ${util.inspect(expectedOptions)} for ${tag}`, () => { 9 | const actualOptions = tagToCypressOptions(tag); 10 | 11 | assert.deepStrictEqual(actualOptions, expectedOptions); 12 | }); 13 | } 14 | 15 | describe("tagToCypressOptions", () => { 16 | example("@animationDistanceThreshold(5)", { animationDistanceThreshold: 5 }); 17 | // example("@baseUrl('http://www.foo.com')'", { baseUrl: "http://www.foo.com" }); 18 | example("@blockHosts('http://www.foo.com')", { 19 | blockHosts: "http://www.foo.com", 20 | }); 21 | example("@blockHosts('http://www.foo.com','http://www.bar.com')", { 22 | blockHosts: ["http://www.foo.com", "http://www.bar.com"], 23 | }); 24 | example("@defaultCommandTimeout(5)", { defaultCommandTimeout: 5 }); 25 | example("@execTimeout(5)", { execTimeout: 5 }); 26 | // example("@experimentalSessionAndOrigin(true)", { 27 | // experimentalSessionAndOrigin: 5, 28 | // }); 29 | // example("@experimentalSessionAndOrigin(false)", { 30 | // experimentalSessionAndOrigin: 5, 31 | // }); 32 | example("@includeShadowDom(true)", { includeShadowDom: true }); 33 | example("@includeShadowDom(false)", { includeShadowDom: false }); 34 | example("@keystrokeDelay(5)", { keystrokeDelay: 5 }); 35 | example("@numTestsKeptInMemory(5)", { numTestsKeptInMemory: 5 }); 36 | example("@pageLoadTimeout(5)", { pageLoadTimeout: 5 }); 37 | example("@redirectionLimit(5)", { redirectionLimit: 5 }); 38 | example("@requestTimeout(5)", { requestTimeout: 5 }); 39 | example("@responseTimeout(5)", { responseTimeout: 5 }); 40 | example("@retries(5)", { retries: 5 }); 41 | example("@retries(runMode=5)", { retries: { runMode: 5 } }); 42 | example("@retries(openMode=5)", { retries: { openMode: 5 } }); 43 | example("@retries(runMode=5,openMode=10)", { 44 | retries: { runMode: 5, openMode: 10 }, 45 | }); 46 | example("@retries(openMode=10,runMode=5)", { 47 | retries: { runMode: 5, openMode: 10 }, 48 | }); 49 | example("@screenshotOnRunFailure(true)", { screenshotOnRunFailure: true }); 50 | example("@screenshotOnRunFailure(false)", { screenshotOnRunFailure: false }); 51 | example("@scrollBehavior('center')", { scrollBehavior: "center" }); 52 | example("@scrollBehavior('top')", { scrollBehavior: "top" }); 53 | example("@scrollBehavior('bottom')", { scrollBehavior: "bottom" }); 54 | example("@scrollBehavior('nearest')", { scrollBehavior: "nearest" }); 55 | example("@slowTestThreshold(5)", { slowTestThreshold: 5 }); 56 | example("@viewportHeight(720)", { viewportHeight: 720 }); 57 | example("@viewportWidth(1280)", { viewportWidth: 1280 }); 58 | example("@waitForAnimations(true)", { waitForAnimations: true }); 59 | example("@waitForAnimations(false)", { waitForAnimations: false }); 60 | }); 61 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import { messages } from "@cucumber/messages"; 2 | 3 | import DataTable from "./data_table"; 4 | 5 | import { 6 | IHookBody, 7 | IParameterTypeDefinition, 8 | IStepDefinitionBody, 9 | } from "./types"; 10 | 11 | import * as Methods from "./methods"; 12 | 13 | declare global { 14 | interface Window { 15 | testState: { 16 | gherkinDocument: messages.IGherkinDocument; 17 | pickles: messages.IPickle[]; 18 | pickle: messages.IPickle; 19 | }; 20 | } 21 | } 22 | 23 | export { resolve as resolvePreprocessorConfiguration } from "./preprocessor-configuration"; 24 | 25 | export { getStepDefinitionPaths } from "./step-definitions"; 26 | 27 | export { 28 | default as addCucumberPreprocessorPlugin, 29 | beforeRunHandler, 30 | afterRunHandler, 31 | beforeSpecHandler, 32 | afterSpecHandler, 33 | afterScreenshotHandler, 34 | } from "./add-cucumber-preprocessor-plugin"; 35 | 36 | /** 37 | * Everything below exist merely for the purpose of being nice with TypeScript. All of these methods 38 | * are exclusively used in the browser and the browser field in package.json points to ./methods.ts. 39 | */ 40 | function createUnimplemented() { 41 | return new Error("Cucumber methods aren't available in a node environment"); 42 | } 43 | 44 | export { NOT_FEATURE_ERROR } from "./methods"; 45 | 46 | export function isFeature(): boolean { 47 | throw createUnimplemented(); 48 | } 49 | 50 | export function doesFeatureMatch(expression: string): boolean { 51 | throw createUnimplemented(); 52 | } 53 | 54 | export function defineStep( 55 | description: string | RegExp, 56 | implementation: IStepDefinitionBody 57 | ) { 58 | throw createUnimplemented(); 59 | } 60 | 61 | export { 62 | defineStep as Given, 63 | defineStep as When, 64 | defineStep as Then, 65 | defineStep as And, 66 | defineStep as But, 67 | }; 68 | 69 | export function Step( 70 | world: Mocha.Context, 71 | text: string, 72 | argument?: DataTable | string 73 | ) { 74 | throw createUnimplemented(); 75 | } 76 | 77 | export function defineParameterType(options: IParameterTypeDefinition) { 78 | throw createUnimplemented(); 79 | } 80 | 81 | export function attach(data: string | ArrayBuffer, mediaType?: string) { 82 | throw createUnimplemented(); 83 | } 84 | 85 | export function Before(options: { tags?: string }, fn: IHookBody): void; 86 | export function Before(fn: IHookBody): void; 87 | export function Before( 88 | optionsOrFn: IHookBody | { tags?: string }, 89 | maybeFn?: IHookBody 90 | ) { 91 | throw createUnimplemented(); 92 | } 93 | 94 | export function After(options: { tags?: string }, fn: IHookBody): void; 95 | export function After(fn: IHookBody): void; 96 | export function After( 97 | optionsOrFn: IHookBody | { tags?: string }, 98 | maybeFn?: IHookBody 99 | ) { 100 | throw createUnimplemented(); 101 | } 102 | 103 | export { default as DataTable } from "./data_table"; 104 | -------------------------------------------------------------------------------- /docs/json-report.md: -------------------------------------------------------------------------------- 1 | # JSON reports 2 | 3 | JSON reports can be enabled using the `json.enabled` property. The preprocessor uses [cosmiconfig](https://github.com/davidtheclark/cosmiconfig), which means you can place configuration options in EG. `.cypress-cucumber-preprocessorrc.json` or `package.json`. An example configuration is shown below. 4 | 5 | ```json 6 | { 7 | "json": { 8 | "enabled": true 9 | } 10 | } 11 | ``` 12 | 13 | This **requires** you to have registered this module in your [plugin file](https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Plugins-file), as shown below. 14 | 15 | ```ts 16 | import { addCucumberPreprocessorPlugin } from "@klaveness/cypress-cucumber-preprocessor"; 17 | 18 | export default async ( 19 | on: Cypress.PluginEvents, 20 | config: Cypress.PluginConfigOptions 21 | ): Promise => { 22 | await addCucumberPreprocessorPlugin(on, config); 23 | 24 | // Make sure to return the config object as it might have been modified by the plugin. 25 | return config; 26 | } 27 | ``` 28 | 29 | This also **requires** you to have downloaded and installed the [cucumber-json-formatter](https://github.com/cucumber/json-formatter) **yourself**. Arch Linux users can install it from [AUR](https://aur.archlinux.org/packages/cucumber-json-formatter). 30 | 31 | The location of the executable is configurable through the `json.formatter` property, but it will by default search for `cucumber-json-formatter` in your `PATH`. 32 | 33 | The report is outputted to `cucumber-report.json` in the project directory, but can be configured through the `json.output` property. 34 | 35 | ## Screenshots 36 | 37 | Screenshots are automatically added to JSON reports, including that of failed tests (unless you have disabled `screenshotOnRunFailure`). 38 | 39 | ## Attachments 40 | 41 | Text, images and other data can be added to the output of the messages and JSON reports with attachments. 42 | 43 | ```ts 44 | import { Given, attach } from "@klaveness/cypress-cucumber-preprocessor"; 45 | 46 | Given("a step", function() { 47 | attach("foobar"); 48 | }); 49 | ``` 50 | 51 | By default, text is saved with a MIME type of text/plain. You can also specify a different MIME type. 52 | 53 | ```ts 54 | import { Given, attach } from "@klaveness/cypress-cucumber-preprocessor"; 55 | 56 | Given("a step", function() { 57 | attach('{ "name": "foobar" }', "application/json"); 58 | }); 59 | ``` 60 | 61 | Images and other binary data can be attached using a ArrayBuffer. The data will be base64 encoded in the output. 62 | 63 | ```ts 64 | import { Given, attach } from "@klaveness/cypress-cucumber-preprocessor"; 65 | 66 | Given("a step", function() { 67 | attach(new TextEncoder().encode("foobar").buffer, "text/plain"); 68 | }); 69 | ``` 70 | 71 | If you've already got a base64-encoded string, you can prefix your mime type with `base64:` to indicate this. 72 | 73 | ```ts 74 | import { Given, attach } from "@klaveness/cypress-cucumber-preprocessor"; 75 | 76 | Given("a step", function() { 77 | attach("Zm9vYmFy", "base64:text/plain"); 78 | }); 79 | ``` 80 | -------------------------------------------------------------------------------- /test/run-all-specs.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { promises as fs } from "fs"; 3 | import child_process from "child_process"; 4 | import { assertAndReturn } from "../lib/assertions"; 5 | 6 | function aggregatedTitle(test: Mocha.Suite | Mocha.Test): string { 7 | if (test.parent?.title) { 8 | return `${aggregatedTitle(test.parent)} - ${test.title}`; 9 | } else { 10 | return test.title; 11 | } 12 | } 13 | 14 | async function writeFile(filePath: string, fileContent: string) { 15 | await fs.mkdir(path.dirname(filePath), { recursive: true }); 16 | await fs.writeFile(filePath, fileContent); 17 | } 18 | 19 | const projectPath = path.join(__dirname, ".."); 20 | 21 | describe("Run all specs", () => { 22 | beforeEach(async function () { 23 | const title = aggregatedTitle( 24 | assertAndReturn( 25 | this.test?.ctx?.currentTest, 26 | "Expected hook to have a context and a test" 27 | ) 28 | ); 29 | 30 | this.tmpDir = path.join(projectPath, "tmp", title.replace(/[\(\)\?]/g, "")); 31 | 32 | await fs.rm(this.tmpDir, { recursive: true, force: true }); 33 | 34 | await writeFile( 35 | path.join(this.tmpDir, "cypress.json"), 36 | JSON.stringify({ 37 | testFiles: "**/*.feature", 38 | video: false, 39 | }) 40 | ); 41 | 42 | await writeFile( 43 | path.join(this.tmpDir, "cypress", "plugins", "index.js"), 44 | ` 45 | const { createEsbuildPlugin } = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 46 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 47 | 48 | module.exports = (on, config) => { 49 | on( 50 | "file:preprocessor", 51 | createBundler({ 52 | plugins: [createEsbuildPlugin(config)] 53 | }) 54 | ); 55 | } 56 | ` 57 | ); 58 | 59 | await fs.mkdir(path.join(this.tmpDir, "node_modules", "@klaveness"), { 60 | recursive: true, 61 | }); 62 | 63 | await fs.symlink( 64 | projectPath, 65 | path.join( 66 | this.tmpDir, 67 | "node_modules", 68 | "@klaveness", 69 | "cypress-cucumber-preprocessor" 70 | ) 71 | ); 72 | }); 73 | 74 | it("should work fine with seemingly (?) ambiguous step definitions", async function () { 75 | const feature = ` 76 | Feature: 77 | Scenario: 78 | Given a step 79 | `; 80 | 81 | const steps = ` 82 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 83 | Given("a step", function() {}); 84 | `; 85 | 86 | await writeFile( 87 | path.join(this.tmpDir, "cypress", "integration", "a.feature"), 88 | feature 89 | ); 90 | 91 | await writeFile( 92 | path.join(this.tmpDir, "cypress", "integration", "a.ts"), 93 | steps 94 | ); 95 | 96 | await writeFile( 97 | path.join(this.tmpDir, "cypress", "integration", "b.feature"), 98 | feature 99 | ); 100 | 101 | await writeFile( 102 | path.join(this.tmpDir, "cypress", "integration", "b.ts"), 103 | steps 104 | ); 105 | 106 | child_process.spawnSync( 107 | path.join(projectPath, "node_modules", ".bin", "cypress"), 108 | ["open"], 109 | { cwd: this.tmpDir, stdio: "inherit" } 110 | ); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@klaveness/cypress-cucumber-preprocessor", 3 | "version": "11.2.0", 4 | "author": "Jonas Amundsen", 5 | "license": "MIT", 6 | "homepage": "https://github.com/Klaveness-Digital/cypress-cucumber-preprocessor", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Klaveness-Digital/cypress-cucumber-preprocessor.git" 10 | }, 11 | "keywords": [ 12 | "cypress", 13 | "cypress-plugin", 14 | "cypress-preprocessor" 15 | ], 16 | "main": "lib/index.js", 17 | "types": "lib/index.d.ts", 18 | "browser": "methods.js", 19 | "files": [ 20 | "lib/**/*.js", 21 | "lib/**/*.d.ts", 22 | "browserify.js", 23 | "browserify.d.ts", 24 | "esbuild.js", 25 | "esbuild.d.ts", 26 | "methods.js", 27 | "methods.d.ts", 28 | "webpack.js", 29 | "webpack.d.ts" 30 | ], 31 | "scripts": { 32 | "clean": "rm {browserify,esbuild,methods,webpack}.{js,d.ts} && bash -O globstar -c 'rm lib/**/*.{js,d.ts} test/**/*.{js,d.ts}'", 33 | "build": "tsc", 34 | "watch": "tsc --watch", 35 | "fmt": "prettier --ignore-path .gitignore --write '**/*.ts'", 36 | "test": "npm run test:fmt && npm run test:types && npm run test:unit && npm run test:integration", 37 | "test:fmt": "prettier --ignore-path .gitignore --check '**/*.ts'", 38 | "test:types": "dtslint --expectOnly types", 39 | "test:unit": "mocha lib/**/*.test.ts", 40 | "test:run-all-specs": "mocha --timeout 0 test/run-all-specs.ts", 41 | "test:integration": "cucumber-js", 42 | "prepublishOnly": "npm run clean && npm run build && npm run test" 43 | }, 44 | "dependencies": { 45 | "@klaveness/cypress-configuration": "^3.0.0", 46 | "@cucumber/cucumber-expressions": "^15.0.1", 47 | "@cucumber/gherkin": "^15.0.2", 48 | "@cucumber/messages": "^13.2.1", 49 | "@cucumber/tag-expressions": "^4.1.0", 50 | "base64-js": "^1.5.1", 51 | "chalk": "^4.1.2", 52 | "cosmiconfig": "^7.0.1", 53 | "debug": "^4.2.0", 54 | "glob": "^7.2.0", 55 | "is-path-inside": "^3.0.3", 56 | "uuid": "^8.3.2" 57 | }, 58 | "devDependencies": { 59 | "@bahmutov/cypress-esbuild-preprocessor": "^2.1.2", 60 | "@cucumber/cucumber": "^8.0.0", 61 | "@cucumber/pretty-formatter": "^1.0.0-alpha.0", 62 | "@cypress/browserify-preprocessor": "^3.0.2", 63 | "@cypress/webpack-preprocessor": "^5.11.1", 64 | "@types/debug": "^4.1.7", 65 | "@types/fs-extra": "^9.0.13", 66 | "@types/glob": "^7.2.0", 67 | "@types/prettier": "^2.6.3", 68 | "@types/stream-buffers": "^3.0.4", 69 | "ast-types": "^0.15.2", 70 | "cypress": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", 71 | "dtslint": "^4.2.1", 72 | "esbuild": "^0.14.23", 73 | "fs-extra": "^10.1.0", 74 | "mocha": "^9.2.1", 75 | "pngjs": "^6.0.0", 76 | "prettier": "^2.5.1", 77 | "recast": "^0.21.1", 78 | "stream-buffers": "^3.0.2", 79 | "strip-indent": "^3.0.0", 80 | "ts-loader": "^9.2.6", 81 | "ts-node": "^10.5.0", 82 | "typescript": "^4.5.5", 83 | "webpack": "^5.69.1" 84 | }, 85 | "peerDependencies": { 86 | "@cypress/browserify-preprocessor": "^3.0.1", 87 | "cypress": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", 88 | "esbuild": "^0.14.23" 89 | }, 90 | "peerDependenciesMeta": { 91 | "@cypress/browserify-preprocessor": { 92 | "optional": true 93 | }, 94 | "esbuild": { 95 | "optional": true 96 | } 97 | }, 98 | "engines": { 99 | "node": ">=14.14.0" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/data_table.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | import { messages } from "@cucumber/messages"; 4 | 5 | import DataTable from "./data_table"; 6 | 7 | describe("DataTable", () => { 8 | describe("table with headers", () => { 9 | const dataTable = 10 | messages.GherkinDocument.Feature.Step.DataTable.fromObject({ 11 | rows: [ 12 | { 13 | cells: [{ value: "header 1" }, { value: "header 2" }], 14 | }, 15 | { 16 | cells: [{ value: "row 1 col 1" }, { value: "row 1 col 2" }], 17 | }, 18 | { 19 | cells: [{ value: "row 2 col 1" }, { value: "row 2 col 2" }], 20 | }, 21 | ], 22 | }); 23 | 24 | describe("rows", () => { 25 | it("returns a 2-D array without the header", () => { 26 | assert.deepStrictEqual(new DataTable(dataTable).rows(), [ 27 | ["row 1 col 1", "row 1 col 2"], 28 | ["row 2 col 1", "row 2 col 2"], 29 | ]); 30 | }); 31 | }); 32 | 33 | describe("hashes", () => { 34 | it("returns an array of object where the keys are the headers", () => { 35 | assert.deepStrictEqual(new DataTable(dataTable).hashes(), [ 36 | { "header 1": "row 1 col 1", "header 2": "row 1 col 2" }, 37 | { "header 1": "row 2 col 1", "header 2": "row 2 col 2" }, 38 | ]); 39 | }); 40 | }); 41 | 42 | describe("transpose", () => { 43 | it("returns a new DataTable, with the data transposed", () => { 44 | assert.deepStrictEqual(new DataTable(dataTable).transpose().raw(), [ 45 | ["header 1", "row 1 col 1", "row 2 col 1"], 46 | ["header 2", "row 1 col 2", "row 2 col 2"], 47 | ]); 48 | }); 49 | }); 50 | }); 51 | 52 | describe("table without headers", () => { 53 | const dataTable = 54 | messages.GherkinDocument.Feature.Step.DataTable.fromObject({ 55 | rows: [ 56 | { 57 | cells: [{ value: "row 1 col 1" }, { value: "row 1 col 2" }], 58 | }, 59 | { 60 | cells: [{ value: "row 2 col 1" }, { value: "row 2 col 2" }], 61 | }, 62 | ], 63 | }); 64 | 65 | describe("raw", () => { 66 | it("returns a 2-D array", () => { 67 | assert.deepStrictEqual(new DataTable(dataTable).raw(), [ 68 | ["row 1 col 1", "row 1 col 2"], 69 | ["row 2 col 1", "row 2 col 2"], 70 | ]); 71 | }); 72 | }); 73 | 74 | describe("rowsHash", () => { 75 | it("returns an object where the keys are the first column", () => { 76 | assert.deepStrictEqual(new DataTable(dataTable).rowsHash(), { 77 | "row 1 col 1": "row 1 col 2", 78 | "row 2 col 1": "row 2 col 2", 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | describe("table with something other than 2 columns", () => { 85 | const dataTable = 86 | messages.GherkinDocument.Feature.Step.DataTable.fromObject({ 87 | rows: [ 88 | { 89 | cells: [{ value: "row 1 col 1" }], 90 | }, 91 | { 92 | cells: [{ value: "row 2 col 1" }], 93 | }, 94 | ], 95 | }); 96 | 97 | describe("rowsHash", () => { 98 | it("throws an error if not all rows have two columns", function () { 99 | assert.throws(() => { 100 | new DataTable(dataTable).rowsHash(); 101 | }, new Error("rowsHash can only be called on a data table where all rows have exactly two columns")); 102 | }); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /features/tags/skip_tag.feature: -------------------------------------------------------------------------------- 1 | Feature: @skip tag 2 | 3 | Rules: 4 | - Tests tagged with @skip are skipped 5 | - Presence of this tag override any other tag filter 6 | 7 | Scenario: 1 / 2 scenarios tagged with @skip 8 | Given a file named "cypress/e2e/a.feature" with: 9 | """ 10 | Feature: a feature 11 | Scenario: a scenario 12 | Given a step 13 | 14 | @skip 15 | Scenario: another scenario 16 | """ 17 | And a file named "cypress/support/step_definitions/steps.js" with: 18 | """ 19 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 20 | Given("a step", function(table) {}); 21 | """ 22 | When I run cypress 23 | Then it should appear to have run the scenario "a scenario" 24 | And it should appear to have skipped the scenario "another scenario" 25 | 26 | Scenario: 2 / 2 scenarios tagged with @skip 27 | Given a file named "cypress/e2e/a.feature" with: 28 | """ 29 | Feature: a feature 30 | @skip 31 | Scenario: a scenario 32 | Given a step 33 | 34 | @skip 35 | Scenario: another scenario 36 | """ 37 | And a file named "cypress/support/step_definitions/steps.js" with: 38 | """ 39 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 40 | Given("a step", function(table) {}); 41 | """ 42 | When I run cypress 43 | Then it should appear as if both tests were skipped 44 | 45 | Scenario: 1 / 2 example table tagged with @skip 46 | Given a file named "cypress/e2e/a.feature" with: 47 | """ 48 | Feature: a feature 49 | Scenario Outline: a scenario 50 | Given a step 51 | 52 | Examples: 53 | | value | 54 | | foo | 55 | 56 | @skip 57 | Examples: 58 | | value | 59 | | bar | 60 | 61 | Scenario: another scenario 62 | """ 63 | And a file named "cypress/support/step_definitions/steps.js" with: 64 | """ 65 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 66 | Given("a step", function(table) {}); 67 | """ 68 | When I run cypress 69 | Then it should appear to have run the scenario "a scenario (example #1)" 70 | And it should appear to have skipped the scenario "a scenario (example #2)" 71 | 72 | Scenario: 2 / 2 example table tagged with @skip 73 | Given a file named "cypress/e2e/a.feature" with: 74 | """ 75 | Feature: a feature 76 | Scenario Outline: a scenario 77 | Given a step 78 | 79 | @skip 80 | Examples: 81 | | value | 82 | | foo | 83 | 84 | @skip 85 | Examples: 86 | | value | 87 | | bar | 88 | 89 | Scenario: another scenario 90 | """ 91 | And a file named "cypress/support/step_definitions/steps.js" with: 92 | """ 93 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 94 | Given("a step", function(table) {}); 95 | """ 96 | When I run cypress 97 | Then it should appear to have skipped the scenario "a scenario (example #1)" 98 | And it should appear to have skipped the scenario "a scenario (example #2)" 99 | 100 | Scenario: specifying tags 101 | Given a file named "cypress/e2e/a.feature" with: 102 | """ 103 | Feature: a feature 104 | @foo 105 | @skip 106 | Scenario: a scenario 107 | Given a step 108 | """ 109 | And a file named "cypress/support/step_definitions/steps.js" with: 110 | """ 111 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 112 | Given("a step", function(table) {}); 113 | """ 114 | When I run cypress with "-e tags=@foo" 115 | Then it should appear to have skipped the scenario "a scenario" 116 | -------------------------------------------------------------------------------- /features/data_table.feature: -------------------------------------------------------------------------------- 1 | Feature: data tables 2 | 3 | Scenario: raw 4 | Given a file named "cypress/e2e/a.feature" with: 5 | """ 6 | Feature: a feature 7 | Scenario: a scenario 8 | Given a table step 9 | | Cucumber | Cucumis sativus | 10 | | Burr Gherkin | Cucumis anguria | 11 | """ 12 | And a file named "cypress/support/step_definitions/steps.js" with: 13 | """ 14 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 15 | Given("a table step", function(table) { 16 | const expected = [ 17 | ["Cucumber", "Cucumis sativus"], 18 | ["Burr Gherkin", "Cucumis anguria"] 19 | ]; 20 | expect(table.raw()).to.deep.equal(expected); 21 | }); 22 | """ 23 | When I run cypress 24 | Then it passes 25 | 26 | Scenario: rows 27 | Given a file named "cypress/e2e/a.feature" with: 28 | """ 29 | Feature: a feature 30 | Scenario: a scenario 31 | Given a table step 32 | | Vegetable | Rating | 33 | | Apricot | 5 | 34 | | Brocolli | 2 | 35 | | Cucumber | 10 | 36 | """ 37 | And a file named "cypress/support/step_definitions/steps.js" with: 38 | """ 39 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 40 | Given("a table step", function(table) { 41 | const expected = [ 42 | ["Apricot", "5"], 43 | ["Brocolli", "2"], 44 | ["Cucumber", "10"] 45 | ]; 46 | expect(table.rows()).to.deep.equal(expected); 47 | }); 48 | """ 49 | When I run cypress 50 | Then it passes 51 | 52 | Scenario: rowsHash 53 | Given a file named "cypress/e2e/a.feature" with: 54 | """ 55 | Feature: a feature 56 | Scenario: a scenario 57 | Given a table step 58 | | Cucumber | Cucumis sativus | 59 | | Burr Gherkin | Cucumis anguria | 60 | """ 61 | And a file named "cypress/support/step_definitions/steps.js" with: 62 | """ 63 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 64 | Given("a table step", function(table) { 65 | const expected = { 66 | "Cucumber": "Cucumis sativus", 67 | "Burr Gherkin": "Cucumis anguria" 68 | }; 69 | expect(table.rowsHash()).to.deep.equal(expected); 70 | }); 71 | """ 72 | When I run cypress 73 | Then it passes 74 | 75 | Scenario: hashes 76 | Given a file named "cypress/e2e/a.feature" with: 77 | """ 78 | Feature: a feature 79 | Scenario: a scenario 80 | Given a table step 81 | | Vegetable | Rating | 82 | | Apricot | 5 | 83 | | Brocolli | 2 | 84 | | Cucumber | 10 | 85 | """ 86 | And a file named "cypress/support/step_definitions/steps.js" with: 87 | """ 88 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 89 | Given("a table step", function(table) { 90 | const expected = [ 91 | {"Vegetable": "Apricot", "Rating": "5"}, 92 | {"Vegetable": "Brocolli", "Rating": "2"}, 93 | {"Vegetable": "Cucumber", "Rating": "10"} 94 | ]; 95 | expect(table.hashes()).to.deep.equal(expected); 96 | }); 97 | """ 98 | When I run cypress 99 | Then it passes 100 | 101 | Scenario: empty cells 102 | Given a file named "cypress/e2e/a.feature" with: 103 | """ 104 | Feature: a feature 105 | Scenario: a scenario 106 | Given a table step 107 | | Vegetable | Rating | 108 | | Apricot | | 109 | | Brocolli | | 110 | | Cucumber | | 111 | """ 112 | And a file named "cypress/support/step_definitions/steps.js" with: 113 | """ 114 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 115 | Given("a table step", function(table) { 116 | const expected = [ 117 | ["Apricot", ""], 118 | ["Brocolli", ""], 119 | ["Cucumber", ""] 120 | ]; 121 | expect(table.rows()).to.deep.equal(expected); 122 | }); 123 | """ 124 | When I run cypress 125 | Then it passes 126 | -------------------------------------------------------------------------------- /lib/methods.ts: -------------------------------------------------------------------------------- 1 | import parse from "@cucumber/tag-expressions"; 2 | import { fromByteArray } from "base64-js"; 3 | import { assertAndReturn } from "./assertions"; 4 | import { collectTagNames } from "./ast-helpers"; 5 | 6 | import { 7 | INTERNAL_PROPERTY_NAME, 8 | TASK_CREATE_STRING_ATTACHMENT, 9 | } from "./constants"; 10 | import { InternalProperties } from "./create-tests"; 11 | 12 | import DataTable from "./data_table"; 13 | 14 | import { getRegistry } from "./registry"; 15 | 16 | import { 17 | IHookBody, 18 | IParameterTypeDefinition, 19 | IStepDefinitionBody, 20 | } from "./types"; 21 | 22 | function defineStep( 23 | description: string | RegExp, 24 | implementation: IStepDefinitionBody 25 | ) { 26 | getRegistry().defineStep(description, implementation); 27 | } 28 | 29 | function runStepDefininition( 30 | world: Mocha.Context, 31 | text: string, 32 | argument?: DataTable | string 33 | ) { 34 | getRegistry().runStepDefininition(world, text, argument); 35 | } 36 | 37 | function defineParameterType(options: IParameterTypeDefinition) { 38 | getRegistry().defineParameterType(options); 39 | } 40 | 41 | function defineBefore(options: { tags?: string }, fn: IHookBody): void; 42 | function defineBefore(fn: IHookBody): void; 43 | function defineBefore( 44 | optionsOrFn: IHookBody | { tags?: string }, 45 | maybeFn?: IHookBody 46 | ) { 47 | if (typeof optionsOrFn === "function") { 48 | getRegistry().defineBefore({}, optionsOrFn); 49 | } else if (typeof optionsOrFn === "object" && typeof maybeFn === "function") { 50 | getRegistry().defineBefore(optionsOrFn, maybeFn); 51 | } else { 52 | throw new Error("Unexpected argument for Before hook"); 53 | } 54 | } 55 | 56 | function defineAfter(options: { tags?: string }, fn: IHookBody): void; 57 | function defineAfter(fn: IHookBody): void; 58 | function defineAfter( 59 | optionsOrFn: IHookBody | { tags?: string }, 60 | maybeFn?: IHookBody 61 | ) { 62 | if (typeof optionsOrFn === "function") { 63 | getRegistry().defineAfter({}, optionsOrFn); 64 | } else if (typeof optionsOrFn === "object" && typeof maybeFn === "function") { 65 | getRegistry().defineAfter(optionsOrFn, maybeFn); 66 | } else { 67 | throw new Error("Unexpected argument for After hook"); 68 | } 69 | } 70 | 71 | function createStringAttachment( 72 | data: string, 73 | mediaType: string, 74 | encoding: "BASE64" | "IDENTITY" 75 | ) { 76 | cy.task(TASK_CREATE_STRING_ATTACHMENT, { 77 | data, 78 | mediaType, 79 | encoding, 80 | }); 81 | } 82 | 83 | export function attach(data: string | ArrayBuffer, mediaType?: string) { 84 | if (typeof data === "string") { 85 | mediaType = mediaType ?? "text/plain"; 86 | 87 | if (mediaType.startsWith("base64:")) { 88 | createStringAttachment(data, mediaType.replace("base64:", ""), "BASE64"); 89 | } else { 90 | createStringAttachment(data, mediaType ?? "text/plain", "IDENTITY"); 91 | } 92 | } else if (data instanceof ArrayBuffer) { 93 | if (typeof mediaType !== "string") { 94 | throw Error("ArrayBuffer attachments must specify a media type"); 95 | } 96 | 97 | createStringAttachment( 98 | fromByteArray(new Uint8Array(data)), 99 | mediaType, 100 | "BASE64" 101 | ); 102 | } else { 103 | throw Error("Invalid attachment data: must be a ArrayBuffer or string"); 104 | } 105 | } 106 | 107 | function isFeature() { 108 | return Cypress.env(INTERNAL_PROPERTY_NAME) != null; 109 | } 110 | 111 | export const NOT_FEATURE_ERROR = 112 | "Expected to find internal properties, but didn't. This is likely because you're calling doesFeatureMatch() in a non-feature spec. Use doesFeatureMatch() in combination with isFeature() if you have both feature and non-feature specs"; 113 | 114 | function doesFeatureMatch(expression: string) { 115 | const { pickle } = assertAndReturn( 116 | Cypress.env(INTERNAL_PROPERTY_NAME), 117 | NOT_FEATURE_ERROR 118 | ) as InternalProperties; 119 | 120 | return parse(expression).evaluate(collectTagNames(pickle.tags)); 121 | } 122 | 123 | export { 124 | isFeature, 125 | doesFeatureMatch, 126 | defineStep as Given, 127 | defineStep as When, 128 | defineStep as Then, 129 | defineStep as And, 130 | defineStep as But, 131 | defineStep, 132 | runStepDefininition as Step, 133 | defineParameterType, 134 | defineBefore as Before, 135 | defineAfter as After, 136 | }; 137 | -------------------------------------------------------------------------------- /docs/cucumber-basics.md: -------------------------------------------------------------------------------- 1 | # Expressions 2 | 3 | A step definition’s *expression* can either be a regular expression or a [cucumber expression](https://github.com/cucumber/cucumber-expressions#readme). The examples in this section use cucumber expressions. If you prefer to use regular expressions, each *capture group* from the match will be passed as arguments to the step definition’s function. 4 | 5 | ```ts 6 | Given(/I have {int} cukes in my belly/, (cukes: number) => {}); 7 | ``` 8 | 9 | # Arguments 10 | 11 | Steps can be accompanied by [doc strings](https://cucumber.io/docs/gherkin/reference/#doc-strings) or [data tables](https://cucumber.io/docs/gherkin/reference/#data-tables), both which will be passed to the step definition as the last argument, as shown below. 12 | 13 | ```gherkin 14 | Feature: a feature 15 | Scenario: a scenario 16 | Given a table step 17 | | Cucumber | Cucumis sativus | 18 | | Burr Gherkin | Cucumis anguria | 19 | ``` 20 | 21 | ```ts 22 | import { Given, DataTable } from "@klaveness/cypress-cucumber-preprocessor"; 23 | 24 | Given(/^a table step$/, (table: DataTable) => { 25 | const expected = [ 26 | ["Cucumber", "Cucumis sativus"], 27 | ["Burr Gherkin", "Cucumis anguria"] 28 | ]; 29 | assert.deepEqual(table.raw(), expected); 30 | }); 31 | ``` 32 | 33 | See [here](https://github.com/cucumber/cucumber-js/blob/main/docs/support_files/data_table_interface.md) for `DataTable`'s interface. 34 | 35 | # Custom parameter types 36 | 37 | Custom parameter types can be registered using `defineParameterType()`. They share the same scope as tests and you can invoke `defineParameterType()` anywhere you define steps, though the order of definition is unimportant. The table below explains the various arguments you can pass when defining a parameter type. 38 | 39 | | Argument | Description | 40 | | ------------- | ----------- | 41 | | `name` | The name the parameter type will be recognised by in output parameters. 42 | | `regexp` | A regexp that will match the parameter. May include capture groups. 43 | | `transformer` | A function or method that transforms the match from the regexp. Must have arity 1 if the regexp doesn't have any capture groups. Otherwise the arity must match the number of capture groups in `regexp`. | 44 | 45 | # Pending steps 46 | 47 | You can return `"pending"` from a step defintion or a chain to mark a step as pending. This will halt the execution and Cypress will report the test as "skipped". 48 | 49 | ```ts 50 | import { When } from "@klaveness/cypress-cucumber-preprocessor"; 51 | 52 | When("a step", () => { 53 | return "pending"; 54 | }); 55 | ``` 56 | 57 | ```ts 58 | import { When } from "@klaveness/cypress-cucumber-preprocessor"; 59 | 60 | When("a step", () => { 61 | cy.then(() => { 62 | return "pending"; 63 | }); 64 | }); 65 | ``` 66 | 67 | # Nested steps 68 | 69 | You can invoke other steps from a step using `Step()`, as shown below. 70 | 71 | ```ts 72 | import { When, Step } from "@klaveness/cypress-cucumber-preprocessor"; 73 | 74 | When("I fill in the entire form", function () { 75 | Step(this, 'I fill in "john.doe" for "Username"'); 76 | Step(this, 'I fill in "password" for "Password"'); 77 | }); 78 | ``` 79 | 80 | `Step()` optionally accepts a `DataTable` or `string` argument. 81 | 82 | ```ts 83 | import { 84 | When, 85 | Step, 86 | DataTable 87 | } from "@klaveness/cypress-cucumber-preprocessor"; 88 | 89 | When("I fill in the entire form", function () { 90 | Step( 91 | this, 92 | "I fill in the value", 93 | new DataTable([ 94 | ["Field", "Value"], 95 | ["Username", "john.doe"], 96 | ["Password", "password"] 97 | ]) 98 | ); 99 | }); 100 | ``` 101 | 102 | # Hooks 103 | 104 | `Before()` and `After()` is similar to Cypress' `beforeEach()` and `afterEach()`, but they can be selected to conditionally run based on the tags of each scenario, as shown below. Furthermore, failure in these hooks does **not** result in remaining tests being skipped. This is contrary to Cypress' `beforeEach` and `afterEach`. 105 | 106 | ```ts 107 | import { Before } from "@klaveness/cypress-cucumber-preprocessor"; 108 | 109 | Before(function () { 110 | // This hook will be executed before all scenarios. 111 | }); 112 | 113 | Before({ tags: "@foo" }, function () { 114 | // This hook will be executed before scenarios tagged with @foo. 115 | }); 116 | 117 | Before({ tags: "@foo and @bar" }, function () { 118 | // This hook will be executed before scenarios tagged with @foo and @bar. 119 | }); 120 | 121 | Before({ tags: "@foo or @bar" }, function () { 122 | // This hook will be executed before scenarios tagged with @foo or @bar. 123 | }); 124 | ``` 125 | -------------------------------------------------------------------------------- /features/step_definitions/cli_steps.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | import { Given, When, Then } from "@cucumber/cucumber"; 3 | import assert from "assert"; 4 | import childProcess from "child_process"; 5 | 6 | function execAsync( 7 | command: string 8 | ): Promise<{ stdout: string; stderr: string }> { 9 | return new Promise((resolve, reject) => { 10 | childProcess.exec(command, (error, stdout, stderr) => { 11 | if (error) { 12 | reject(error); 13 | } else { 14 | resolve({ stdout, stderr }); 15 | } 16 | }); 17 | }); 18 | } 19 | 20 | When("I run cypress", { timeout: 60 * 1000 }, async function () { 21 | await this.run(); 22 | }); 23 | 24 | When( 25 | "I run cypress with {string}", 26 | { timeout: 60 * 1000 }, 27 | async function (unparsedArgs) { 28 | // Use user's preferred shell to split args. 29 | const { stdout } = await execAsync( 30 | `node -p "JSON.stringify(process.argv)" -- ${unparsedArgs}` 31 | ); 32 | 33 | // Drop 1st arg, which is the path of node. 34 | const [, ...args] = JSON.parse(stdout); 35 | 36 | await this.run(args); 37 | } 38 | ); 39 | 40 | When( 41 | "I run cypress with environment variables", 42 | { timeout: 60 * 1000 }, 43 | async function (table) { 44 | await this.run([], Object.fromEntries(table.rows())); 45 | } 46 | ); 47 | 48 | Then("it passes", function () { 49 | assert.equal(this.lastRun.exitCode, 0, "Expected a zero exit code"); 50 | }); 51 | 52 | Then("it fails", function () { 53 | assert.notEqual(this.lastRun.exitCode, 0, "Expected a non-zero exit code"); 54 | this.verifiedLastRunError = true; 55 | }); 56 | 57 | Then("it should appear as if only a single test ran", function () { 58 | assert.match(this.lastRun.stdout, /All specs passed!\s+\d+ms\s+1\s+1\D/); 59 | }); 60 | 61 | Then("it should appear as if both tests ran", function () { 62 | assert.match(this.lastRun.stdout, /All specs passed!\s+\d+ms\s+2\s+2\D/); 63 | }); 64 | 65 | Then("it should appear as if both tests were skipped", function () { 66 | assert.match( 67 | this.lastRun.stdout, 68 | /All specs passed!\s+\d+ms\s+2\s+-\s+-\s+2\D/ 69 | ); 70 | }); 71 | 72 | Then("it should appear to not have ran spec {string}", function (spec) { 73 | assert.doesNotMatch( 74 | this.lastRun.stdout, 75 | new RegExp("Running:\\s+" + rescape(spec)) 76 | ); 77 | }); 78 | 79 | Then( 80 | "it should appear to have ran spec {string} and {string}", 81 | function (a, b) { 82 | for (const spec of [a, b]) { 83 | assert.match( 84 | this.lastRun.stdout, 85 | new RegExp("Running:\\s+" + rescape(spec)) 86 | ); 87 | } 88 | } 89 | ); 90 | 91 | Then("I should not see {string} in the output", function (string) { 92 | if (this.lastRun.stdout.includes(string)) { 93 | assert.fail(`Expected to not find ${util.inspect(string)}, but did`); 94 | } 95 | }); 96 | 97 | /** 98 | * Shamelessly copied from the RegExp.escape proposal. 99 | */ 100 | const rescape = (s: string) => String(s).replace(/[\\^$*+?.()|[\]{}]/g, "\\$&"); 101 | 102 | const runScenarioExpr = (scenarioName: string) => 103 | new RegExp(`(?:✓|√) ${rescape(scenarioName)}( \\(\\d+ms\\))?\\n`); 104 | 105 | const pendingScenarioExpr = (scenarioName: string) => 106 | new RegExp(`- ${rescape(scenarioName)}\n`); 107 | 108 | Then( 109 | "it should appear to have run the scenario {string}", 110 | function (scenarioName) { 111 | assert.match(this.lastRun.stdout, runScenarioExpr(scenarioName)); 112 | } 113 | ); 114 | 115 | Then( 116 | "it should appear to not have run the scenario {string}", 117 | function (scenarioName) { 118 | assert.doesNotMatch(this.lastRun.stdout, runScenarioExpr(scenarioName)); 119 | } 120 | ); 121 | 122 | Then("it should appear to have run the scenarios", function (scenarioTable) { 123 | for (const { Name: scenarioName } of scenarioTable.hashes()) { 124 | assert.match(this.lastRun.stdout, runScenarioExpr(scenarioName)); 125 | } 126 | }); 127 | 128 | Then( 129 | "it should appear to not have run the scenarios", 130 | function (scenarioTable) { 131 | for (const { Name: scenarioName } of scenarioTable.hashes()) { 132 | assert.doesNotMatch(this.lastRun.stdout, runScenarioExpr(scenarioName)); 133 | } 134 | } 135 | ); 136 | 137 | Then("the output should contain", function (content) { 138 | assert.match(this.lastRun.stdout, new RegExp(rescape(content))); 139 | }); 140 | 141 | Then("the output should match", function (content) { 142 | assert.match(this.lastRun.stdout, new RegExp(content)); 143 | }); 144 | 145 | Then( 146 | "it should appear to have skipped the scenario {string}", 147 | function (scenarioName) { 148 | assert.match(this.lastRun.stdout, pendingScenarioExpr(scenarioName)); 149 | } 150 | ); 151 | -------------------------------------------------------------------------------- /features/support/hooks.ts: -------------------------------------------------------------------------------- 1 | import { version as cypressVersion } from "cypress/package.json"; 2 | import { After, Before, formatterHelpers } from "@cucumber/cucumber"; 3 | import path from "path"; 4 | import assert from "assert"; 5 | import { promises as fs } from "fs"; 6 | import { isPost10, writeFile } from "./helpers"; 7 | 8 | const projectPath = path.join(__dirname, "..", ".."); 9 | 10 | Before(async function ({ gherkinDocument, pickle }) { 11 | assert(gherkinDocument.uri, "Expected gherkinDocument.uri to be present"); 12 | 13 | const relativeUri = path.relative(process.cwd(), gherkinDocument.uri); 14 | 15 | const { line } = formatterHelpers.PickleParser.getPickleLocation({ 16 | gherkinDocument, 17 | pickle, 18 | }); 19 | 20 | this.tmpDir = path.join(projectPath, "tmp", `${relativeUri}_${line}`); 21 | 22 | await fs.rm(this.tmpDir, { recursive: true, force: true }); 23 | 24 | await writeFile( 25 | path.join(this.tmpDir, ".cypress-cucumber-preprocessorrc"), 26 | "{}" 27 | ); 28 | 29 | await writeFile(path.join(this.tmpDir, "cypress", "support", "e2e.js"), ""); 30 | 31 | if (isPost10()) { 32 | await writeFile( 33 | path.join(this.tmpDir, "cypress.config.js"), 34 | ` 35 | const { defineConfig } = require("cypress"); 36 | const setupNodeEvents = require("./setupNodeEvents.js"); 37 | 38 | module.exports = defineConfig({ 39 | e2e: { 40 | specPattern: "cypress/e2e/**/*.feature", 41 | video: false, 42 | setupNodeEvents 43 | } 44 | }) 45 | ` 46 | ); 47 | } else { 48 | await writeFile( 49 | path.join(this.tmpDir, "cypress.json"), 50 | JSON.stringify( 51 | { 52 | testFiles: "**/*.feature", 53 | integrationFolder: "cypress/e2e", 54 | supportFile: "cypress/support/e2e.js", 55 | video: false, 56 | nodeVersion: "system", 57 | }, 58 | null, 59 | 2 60 | ) 61 | ); 62 | } 63 | 64 | await fs.mkdir(path.join(this.tmpDir, "node_modules", "@klaveness"), { 65 | recursive: true, 66 | }); 67 | 68 | await fs.symlink( 69 | projectPath, 70 | path.join( 71 | this.tmpDir, 72 | "node_modules", 73 | "@klaveness", 74 | "cypress-cucumber-preprocessor" 75 | ), 76 | "dir" 77 | ); 78 | }); 79 | 80 | Before({ tags: "not @no-default-plugin" }, async function () { 81 | if (isPost10()) { 82 | await writeFile( 83 | path.join(this.tmpDir, "setupNodeEvents.js"), 84 | ` 85 | const { addCucumberPreprocessorPlugin } = require("@klaveness/cypress-cucumber-preprocessor"); 86 | const { createEsbuildPlugin } = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 87 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 88 | 89 | module.exports = async function setupNodeEvents(on, config) { 90 | await addCucumberPreprocessorPlugin(on, config); 91 | 92 | on( 93 | "file:preprocessor", 94 | createBundler({ 95 | plugins: [createEsbuildPlugin(config)], 96 | }) 97 | ); 98 | 99 | return config; 100 | }; 101 | ` 102 | ); 103 | } else { 104 | await writeFile( 105 | path.join(this.tmpDir, "cypress", "plugins", "index.js"), 106 | ` 107 | const { addCucumberPreprocessorPlugin } = require("@klaveness/cypress-cucumber-preprocessor"); 108 | const { createEsbuildPlugin } = require("@klaveness/cypress-cucumber-preprocessor/esbuild"); 109 | const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); 110 | 111 | module.exports = async (on, config) => { 112 | await addCucumberPreprocessorPlugin(on, config); 113 | 114 | on( 115 | "file:preprocessor", 116 | createBundler({ 117 | plugins: [createEsbuildPlugin(config)] 118 | }) 119 | ); 120 | 121 | return config; 122 | } 123 | ` 124 | ); 125 | } 126 | }); 127 | 128 | After(function () { 129 | if ( 130 | this.lastRun != null && 131 | this.lastRun.exitCode !== 0 && 132 | !this.verifiedLastRunError 133 | ) { 134 | throw new Error( 135 | `Last run errored unexpectedly. Output:\n\n${this.lastRun.output}` 136 | ); 137 | } 138 | }); 139 | 140 | function addCucumberPreprocessorPlugin(on: any, config: any) { 141 | throw new Error("Function not implemented."); 142 | } 143 | 144 | function createBundler(arg0: { plugins: any[] }): any { 145 | throw new Error("Function not implemented."); 146 | } 147 | 148 | function createEsbuildPlugin(config: any) { 149 | throw new Error("Function not implemented."); 150 | } 151 | -------------------------------------------------------------------------------- /lib/ast-helpers.ts: -------------------------------------------------------------------------------- 1 | import { messages } from "@cucumber/messages"; 2 | 3 | import { assertAndReturn } from "./assertions"; 4 | 5 | export function* traverseGherkinDocument( 6 | gherkinDocument: messages.IGherkinDocument 7 | ) { 8 | yield gherkinDocument; 9 | 10 | if (gherkinDocument.feature) { 11 | yield* traverseFeature(gherkinDocument.feature); 12 | } 13 | } 14 | 15 | function* traverseFeature(feature: messages.GherkinDocument.IFeature) { 16 | yield feature; 17 | 18 | if (feature.location) { 19 | yield feature.location; 20 | } 21 | 22 | if (feature.tags) { 23 | for (const tag of feature.tags) { 24 | yield tag; 25 | } 26 | } 27 | 28 | if (feature.children) { 29 | for (const child of feature.children) { 30 | yield* traverseFeatureChild(child); 31 | } 32 | } 33 | } 34 | 35 | function* traverseFeatureChild( 36 | featureChild: messages.GherkinDocument.Feature.IFeatureChild 37 | ) { 38 | yield featureChild; 39 | 40 | if (featureChild.rule) { 41 | yield* traverseFeatureRule(featureChild.rule); 42 | } 43 | 44 | if (featureChild.background) { 45 | yield* traverseBackground(featureChild.background); 46 | } 47 | 48 | if (featureChild.scenario) { 49 | yield* traverseScenario(featureChild.scenario); 50 | } 51 | } 52 | 53 | function* traverseFeatureRule( 54 | rule: messages.GherkinDocument.Feature.FeatureChild.IRule 55 | ) { 56 | yield rule; 57 | 58 | if (rule.location) { 59 | yield rule.location; 60 | } 61 | 62 | if (rule.children) { 63 | for (const child of rule.children) { 64 | yield* traverseRuleChild(child); 65 | } 66 | } 67 | } 68 | 69 | function* traverseRuleChild( 70 | ruleChild: messages.GherkinDocument.Feature.FeatureChild.IRuleChild 71 | ) { 72 | yield ruleChild; 73 | 74 | if (ruleChild.background) { 75 | yield* traverseBackground(ruleChild.background); 76 | } 77 | 78 | if (ruleChild.scenario) { 79 | yield* traverseScenario(ruleChild.scenario); 80 | } 81 | } 82 | 83 | function* traverseBackground( 84 | backgorund: messages.GherkinDocument.Feature.IBackground 85 | ) { 86 | yield backgorund; 87 | 88 | if (backgorund.location) { 89 | yield backgorund.location; 90 | } 91 | 92 | if (backgorund.steps) { 93 | for (const step of backgorund.steps) { 94 | yield* traverseStep(step); 95 | } 96 | } 97 | } 98 | 99 | function* traverseScenario( 100 | scenario: messages.GherkinDocument.Feature.IScenario 101 | ) { 102 | yield scenario; 103 | 104 | if (scenario.location) { 105 | yield scenario.location; 106 | } 107 | 108 | if (scenario.steps) { 109 | for (const step of scenario.steps) { 110 | yield* traverseStep(step); 111 | } 112 | } 113 | 114 | if (scenario.examples) { 115 | for (const example of scenario.examples) { 116 | yield* traverseExample(example); 117 | } 118 | } 119 | } 120 | 121 | function* traverseStep(step: messages.GherkinDocument.Feature.IStep) { 122 | yield step; 123 | 124 | if (step.location) { 125 | yield step.location; 126 | } 127 | 128 | if (step.docString) { 129 | yield* traverseDocString(step.docString); 130 | } 131 | 132 | if (step.dataTable) { 133 | yield* traverseDataTable(step.dataTable); 134 | } 135 | } 136 | 137 | function* traverseDocString( 138 | docString: messages.GherkinDocument.Feature.Step.IDocString 139 | ) { 140 | yield docString; 141 | 142 | if (docString.location) { 143 | yield docString.location; 144 | } 145 | } 146 | 147 | function* traverseDataTable( 148 | dataTable: messages.GherkinDocument.Feature.Step.IDataTable 149 | ) { 150 | yield dataTable; 151 | 152 | if (dataTable.location) { 153 | yield dataTable.location; 154 | } 155 | 156 | if (dataTable.rows) { 157 | for (const row of dataTable.rows) { 158 | yield* traverseRow(row); 159 | } 160 | } 161 | } 162 | 163 | function* traverseRow(row: messages.GherkinDocument.Feature.ITableRow) { 164 | yield row; 165 | 166 | if (row.location) { 167 | yield row.location; 168 | } 169 | 170 | if (row.cells) { 171 | for (const cell of row.cells) { 172 | yield* traverseCell(cell); 173 | } 174 | } 175 | } 176 | 177 | function* traverseCell( 178 | cell: messages.GherkinDocument.Feature.TableRow.ITableCell 179 | ) { 180 | yield cell; 181 | 182 | if (cell.location) { 183 | yield cell.location; 184 | } 185 | } 186 | 187 | function* traverseExample( 188 | example: messages.GherkinDocument.Feature.Scenario.IExamples 189 | ) { 190 | yield example; 191 | 192 | if (example.location) { 193 | yield example.location; 194 | } 195 | 196 | if (example.tableHeader) { 197 | yield* traverseRow(example.tableHeader); 198 | } 199 | 200 | if (example.tableBody) { 201 | for (const row of example.tableBody) { 202 | yield* traverseRow(row); 203 | } 204 | } 205 | } 206 | 207 | export function collectTagNames( 208 | tags: messages.GherkinDocument.Feature.ITag[] | null | undefined 209 | ) { 210 | return ( 211 | tags?.map((tag) => 212 | assertAndReturn(tag.name, "Expected tag to have a name") 213 | ) ?? [] 214 | ); 215 | } 216 | -------------------------------------------------------------------------------- /lib/tag-parser/parser.ts: -------------------------------------------------------------------------------- 1 | import { TagParserError } from "./errors"; 2 | 3 | import { 4 | isAt, 5 | isClosingParanthesis, 6 | isComma, 7 | isDigit, 8 | isEqual, 9 | isOpeningParanthesis, 10 | isQuote, 11 | isWordChar, 12 | Tokenizer, 13 | } from "./tokenizer"; 14 | 15 | function createUnexpectedEndOfString() { 16 | return new TagParserError("Unexpected end-of-string"); 17 | } 18 | 19 | function createUnexpectedToken( 20 | token: TYield, 21 | expectation: string 22 | ) { 23 | return new Error( 24 | `Unexpected token at ${token.position}: ${token.value} (${expectation})` 25 | ); 26 | } 27 | 28 | function expectToken(token: Token) { 29 | if (token.done) { 30 | throw createUnexpectedEndOfString(); 31 | } 32 | 33 | return token; 34 | } 35 | 36 | function parsePrimitiveToken(token: Token) { 37 | if (token.done) { 38 | throw createUnexpectedEndOfString(); 39 | } 40 | 41 | const value = token.value.value; 42 | 43 | const char = value[0]; 44 | 45 | if (value === "false") { 46 | return false; 47 | } else if (value === "true") { 48 | return true; 49 | } 50 | if (isDigit(char)) { 51 | return parseInt(value); 52 | } else if (isQuote(char)) { 53 | return value.slice(1, -1); 54 | } else { 55 | throw createUnexpectedToken( 56 | token.value, 57 | "expected a string, a boolean or a number" 58 | ); 59 | } 60 | } 61 | 62 | type TYield = T extends Generator ? R : never; 63 | 64 | type TReturn = T extends Generator ? R : never; 65 | 66 | interface IteratorYieldResult { 67 | done?: false; 68 | value: TYield; 69 | } 70 | 71 | interface IteratorReturnResult { 72 | done: true; 73 | value: TReturn; 74 | } 75 | 76 | type IteratorResult = 77 | | IteratorYieldResult> 78 | | IteratorReturnResult>; 79 | 80 | type TokenGenerator = ReturnType; 81 | 82 | type Token = IteratorResult; 83 | 84 | class BufferedGenerator { 85 | private tokens: (IteratorYieldResult | IteratorReturnResult)[] = 86 | []; 87 | 88 | private position = -1; 89 | 90 | constructor(generator: Generator) { 91 | do { 92 | this.tokens.push(generator.next()); 93 | } while (!this.tokens[this.tokens.length - 1].done); 94 | } 95 | 96 | next() { 97 | if (this.position < this.tokens.length - 1) { 98 | this.position++; 99 | } 100 | 101 | return this.tokens[this.position]; 102 | } 103 | 104 | peak(n: number = 1) { 105 | return this.tokens[this.position + n]; 106 | } 107 | } 108 | 109 | type Primitive = string | boolean | number; 110 | 111 | export default class Parser { 112 | public constructor(private content: string) {} 113 | 114 | parse(): Record> { 115 | const tokens = new BufferedGenerator(new Tokenizer(this.content).tokens()); 116 | 117 | let next: Token = expectToken(tokens.next()); 118 | 119 | if (!isAt(next.value.value)) { 120 | throw createUnexpectedToken(next.value, "expected tag to begin with '@'"); 121 | } 122 | 123 | next = expectToken(tokens.next()); 124 | 125 | if (!isWordChar(next.value.value[0])) { 126 | throw createUnexpectedToken( 127 | next.value, 128 | "expected tag to start with a property name" 129 | ); 130 | } 131 | 132 | const propertyName = next.value.value; 133 | 134 | next = expectToken(tokens.next()); 135 | 136 | if (!isOpeningParanthesis(next.value.value)) { 137 | throw createUnexpectedToken(next.value, "expected opening paranthesis"); 138 | } 139 | 140 | const isObjectMode = isEqual(expectToken(tokens.peak(2)).value.value); 141 | const entries: [string, Primitive][] = []; 142 | const values: Primitive[] = []; 143 | 144 | if (isObjectMode) { 145 | while (true) { 146 | const key = expectToken(tokens.next()).value.value; 147 | 148 | next = expectToken(tokens.next()); 149 | 150 | if (!isEqual(next.value.value)) { 151 | throw createUnexpectedToken(next.value, "expected equal sign"); 152 | } 153 | 154 | const value = parsePrimitiveToken(tokens.next()); 155 | 156 | entries.push([key, value]); 157 | 158 | if (!isComma(expectToken(tokens.peak()).value.value)) { 159 | break; 160 | } else { 161 | tokens.next(); 162 | } 163 | } 164 | } else { 165 | while (true) { 166 | const value = parsePrimitiveToken(tokens.next()); 167 | 168 | values.push(value); 169 | 170 | if (!isComma(expectToken(tokens.peak()).value.value)) { 171 | break; 172 | } else { 173 | tokens.next(); 174 | } 175 | } 176 | } 177 | 178 | next = expectToken(tokens.next()); 179 | 180 | if (next.done) { 181 | throw createUnexpectedEndOfString(); 182 | } else if (!isClosingParanthesis(next.value.value)) { 183 | throw createUnexpectedToken(next.value, "expected closing paranthesis"); 184 | } 185 | 186 | if (isObjectMode) { 187 | return { 188 | [propertyName]: Object.fromEntries(entries), 189 | }; 190 | } else { 191 | return { 192 | [propertyName]: values.length === 1 ? values[0] : values, 193 | }; 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /features/step_definitions/json_steps.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import { promises as fs } from "fs"; 4 | import assert from "assert"; 5 | import child_process from "child_process"; 6 | import { toByteArray } from "base64-js"; 7 | import { PNG } from "pngjs"; 8 | import { version as cypressVersion } from "cypress/package.json"; 9 | 10 | function isObject(object: any): object is object { 11 | return typeof object === "object" && object != null; 12 | } 13 | 14 | function hasOwnProperty( 15 | obj: X, 16 | prop: Y 17 | ): obj is X & Record { 18 | return obj.hasOwnProperty(prop); 19 | } 20 | 21 | function* traverseTree(object: any): Generator { 22 | if (!isObject(object)) { 23 | throw new Error(`Expected object, got ${typeof object}`); 24 | } 25 | 26 | yield object; 27 | 28 | for (const property of Object.values(object)) { 29 | if (isObject(property)) { 30 | yield* traverseTree(property); 31 | } 32 | } 33 | } 34 | 35 | function prepareJsonReport(tree: any) { 36 | for (const node of traverseTree(tree)) { 37 | if (hasOwnProperty(node, "duration")) { 38 | node.duration = 0; 39 | } else if (hasOwnProperty(node, "uri") && typeof node.uri === "string") { 40 | node.uri = node.uri.replace(/\\/g, "/"); 41 | } 42 | } 43 | 44 | return tree; 45 | } 46 | 47 | Given("I've ensured cucumber-json-formatter is installed", async () => { 48 | const child = child_process.spawn("which", ["cucumber-json-formatter"], { 49 | stdio: "ignore", 50 | }); 51 | 52 | await new Promise((resolve, reject) => { 53 | child.on("exit", (code) => { 54 | if (code === 0) { 55 | resolve(); 56 | } else { 57 | reject(new Error("cucumber-json-formatter must be installed")); 58 | } 59 | }); 60 | }); 61 | }); 62 | 63 | Then("there should be a messages report", async function () { 64 | await assert.doesNotReject( 65 | () => fs.access(path.join(this.tmpDir, "cucumber-messages.ndjson")), 66 | "Expected there to be a messages file" 67 | ); 68 | }); 69 | 70 | Then("there should be no JSON output", async function () { 71 | await assert.rejects( 72 | () => fs.readFile(path.join(this.tmpDir, "cucumber-report.json")), 73 | { 74 | code: "ENOENT", 75 | }, 76 | "Expected there to be no JSON file" 77 | ); 78 | }); 79 | 80 | Then( 81 | "there should be a JSON output similar to {string}", 82 | async function (fixturePath) { 83 | const absolutejsonPath = path.join(this.tmpDir, "cucumber-report.json"); 84 | 85 | const json = await fs.readFile(absolutejsonPath); 86 | 87 | const absoluteExpectedJsonpath = path.join( 88 | process.cwd(), 89 | "features", 90 | fixturePath 91 | ); 92 | 93 | const actualJsonOutput = prepareJsonReport(JSON.parse(json.toString())); 94 | 95 | if (process.env.WRITE_FIXTURES) { 96 | await fs.writeFile( 97 | absoluteExpectedJsonpath, 98 | JSON.stringify(actualJsonOutput, null, 2) + "\n" 99 | ); 100 | } else { 101 | const expectedJsonOutput = JSON.parse( 102 | (await fs.readFile(absoluteExpectedJsonpath)).toString() 103 | ); 104 | assert.deepStrictEqual(actualJsonOutput, expectedJsonOutput); 105 | } 106 | } 107 | ); 108 | 109 | Then( 110 | "the JSON report should contain an image attachment for what appears to be a screenshot", 111 | async function () { 112 | const absolutejsonPath = path.join(this.tmpDir, "cucumber-report.json"); 113 | 114 | const jsonFile = await fs.readFile(absolutejsonPath); 115 | 116 | const actualJsonOutput = JSON.parse(jsonFile.toString()); 117 | 118 | const embeddings: { data: string; mime_type: string }[] = actualJsonOutput 119 | .flatMap((feature: any) => feature.elements) 120 | .flatMap((element: any) => element.steps) 121 | .flatMap((step: any) => step.embeddings ?? []); 122 | 123 | if (embeddings.length === 0) { 124 | throw new Error("Expected to find an embedding in JSON, but found none"); 125 | } else if (embeddings.length > 1) { 126 | throw new Error( 127 | "Expected to find a single embedding in JSON, but found " + 128 | embeddings.length 129 | ); 130 | } 131 | 132 | const [embedding] = embeddings; 133 | 134 | assert.strictEqual(embedding.mime_type, "image/png"); 135 | 136 | const png = await new Promise((resolve, reject) => { 137 | new PNG().parse( 138 | toByteArray(embedding.data).buffer, 139 | function (error: any, data: any) { 140 | if (error) { 141 | reject(error); 142 | } else { 143 | resolve(data); 144 | } 145 | } 146 | ); 147 | }); 148 | 149 | let expectedDimensions; 150 | 151 | /** 152 | * See https://github.com/cypress-io/cypress/pull/15686 and https://github.com/cypress-io/cypress/pull/17309. 153 | */ 154 | if (cypressVersion.startsWith("7.")) { 155 | expectedDimensions = { 156 | width: 1920, 157 | height: 1080, 158 | }; 159 | } else { 160 | expectedDimensions = { 161 | width: 1280, 162 | height: 720, 163 | }; 164 | } 165 | 166 | const { width: actualWidth, height: actualHeight } = png; 167 | 168 | assert.strictEqual(actualWidth, expectedDimensions.width); 169 | assert.strictEqual(actualHeight, expectedDimensions.height); 170 | } 171 | ); 172 | -------------------------------------------------------------------------------- /features/tags/only_tag.feature: -------------------------------------------------------------------------------- 1 | Feature: @only tag 2 | 3 | Rules: 4 | - In presence of any @only tag, only tests tagged with this should be run 5 | - This behavior is scoped per file 6 | - Presence of this tag override any other tag filter 7 | 8 | Scenario: 1 / 2 scenarios tagged with @only 9 | Given a file named "cypress/e2e/a.feature" with: 10 | """ 11 | Feature: a feature 12 | @only 13 | Scenario: a scenario 14 | Given a step 15 | 16 | Scenario: another scenario 17 | """ 18 | And a file named "cypress/support/step_definitions/steps.js" with: 19 | """ 20 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 21 | Given("a step", function(table) {}); 22 | """ 23 | When I run cypress 24 | Then it should appear to have run the scenario "a scenario" 25 | And it should appear to have skipped the scenario "another scenario" 26 | 27 | Scenario: 2 / 2 scenarios tagged with @only 28 | Given a file named "cypress/e2e/a.feature" with: 29 | """ 30 | Feature: a feature 31 | @only 32 | Scenario: a scenario 33 | Given a step 34 | 35 | @only 36 | Scenario: another scenario 37 | """ 38 | And a file named "cypress/support/step_definitions/steps.js" with: 39 | """ 40 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 41 | Given("a step", function(table) {}); 42 | """ 43 | When I run cypress 44 | Then it should appear as if both tests ran 45 | 46 | Scenario: 1 / 2 example table tagged with @only 47 | Given a file named "cypress/e2e/a.feature" with: 48 | """ 49 | Feature: a feature 50 | Scenario Outline: a scenario 51 | Given a step 52 | 53 | @only 54 | Examples: 55 | | value | 56 | | foo | 57 | 58 | Examples: 59 | | value | 60 | | bar | 61 | 62 | Scenario: another scenario 63 | """ 64 | And a file named "cypress/support/step_definitions/steps.js" with: 65 | """ 66 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 67 | Given("a step", function(table) {}); 68 | """ 69 | When I run cypress 70 | Then it should appear to have run the scenario "a scenario (example #1)" 71 | And it should appear to have skipped the scenario "a scenario (example #2)" 72 | 73 | Scenario: 2 / 2 example table tagged with @only 74 | Given a file named "cypress/e2e/a.feature" with: 75 | """ 76 | Feature: a feature 77 | Scenario Outline: a scenario 78 | Given a step 79 | 80 | @only 81 | Examples: 82 | | value | 83 | | foo | 84 | 85 | @only 86 | Examples: 87 | | value | 88 | | bar | 89 | 90 | Scenario: another scenario 91 | """ 92 | And a file named "cypress/support/step_definitions/steps.js" with: 93 | """ 94 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 95 | Given("a step", function(table) {}); 96 | """ 97 | When I run cypress 98 | Then it should appear to have run the scenario "a scenario (example #1)" 99 | And it should appear to have run the scenario "a scenario (example #2)" 100 | 101 | Scenario: one file with @only, one without 102 | Given a file named "cypress/e2e/a.feature" with: 103 | """ 104 | Feature: a feature 105 | @only 106 | Scenario: a scenario 107 | Given a step 108 | """ 109 | And a file named "cypress/e2e/b.feature" with: 110 | """ 111 | Feature: b feature 112 | Scenario: another scenario 113 | Given a step 114 | """ 115 | And a file named "cypress/support/step_definitions/steps.js" with: 116 | """ 117 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 118 | Given("a step", function(table) {}); 119 | """ 120 | When I run cypress 121 | Then it should appear to have run the scenario "a scenario" 122 | And it should appear to have run the scenario "another scenario" 123 | 124 | Scenario: specifying tags 125 | Given a file named "cypress/e2e/a.feature" with: 126 | """ 127 | Feature: a feature 128 | @only 129 | Scenario: a scenario 130 | Given a step 131 | 132 | @foo 133 | Scenario: another scenario 134 | Given a step 135 | """ 136 | And a file named "cypress/support/step_definitions/steps.js" with: 137 | """ 138 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 139 | Given("a step", function(table) {}); 140 | """ 141 | When I run cypress with "-e tags=@foo" 142 | Then it should appear to have run the scenario "a scenario" 143 | And it should appear to have skipped the scenario "another scenario" 144 | 145 | Scenario: @focus (backwards compatibility) 146 | Given a file named "cypress/e2e/a.feature" with: 147 | """ 148 | Feature: a feature 149 | @focus 150 | Scenario: a scenario 151 | Given a step 152 | 153 | Scenario: another scenario 154 | """ 155 | And a file named "cypress/support/step_definitions/steps.js" with: 156 | """ 157 | const { Given } = require("@klaveness/cypress-cucumber-preprocessor"); 158 | Given("a step", function(table) {}); 159 | """ 160 | When I run cypress 161 | Then it should appear to have run the scenario "a scenario" 162 | And it should appear to have skipped the scenario "another scenario" 163 | -------------------------------------------------------------------------------- /lib/registry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CucumberExpression, 3 | RegularExpression, 4 | Expression, 5 | ParameterTypeRegistry, 6 | ParameterType, 7 | } from "@cucumber/cucumber-expressions"; 8 | 9 | import parse from "@cucumber/tag-expressions"; 10 | 11 | import { v4 as uuid } from "uuid"; 12 | 13 | import { assertAndReturn } from "./assertions"; 14 | 15 | import DataTable from "./data_table"; 16 | 17 | import { 18 | IHookBody, 19 | IParameterTypeDefinition, 20 | IStepDefinitionBody, 21 | } from "./types"; 22 | 23 | interface IStepDefinition { 24 | expression: Expression; 25 | implementation: IStepDefinitionBody; 26 | } 27 | 28 | export type HookKeyword = "Before" | "After"; 29 | 30 | export interface IHook { 31 | id: string; 32 | node: ReturnType; 33 | implementation: IHookBody; 34 | keyword: HookKeyword; 35 | } 36 | 37 | const noopNode = { evaluate: () => true }; 38 | 39 | function parseHookArguments( 40 | options: { tags?: string }, 41 | fn: IHookBody, 42 | keyword: HookKeyword 43 | ): IHook { 44 | return { 45 | id: uuid(), 46 | node: options.tags ? parse(options.tags) : noopNode, 47 | implementation: fn, 48 | keyword, 49 | }; 50 | } 51 | 52 | export class Registry { 53 | private parameterTypeRegistry: ParameterTypeRegistry; 54 | 55 | private preliminaryStepDefinitions: { 56 | description: string | RegExp; 57 | implementation: () => void; 58 | }[] = []; 59 | 60 | private stepDefinitions: IStepDefinition[] = []; 61 | 62 | public beforeHooks: IHook[] = []; 63 | 64 | public afterHooks: IHook[] = []; 65 | 66 | constructor() { 67 | this.defineStep = this.defineStep.bind(this); 68 | this.runStepDefininition = this.runStepDefininition.bind(this); 69 | this.defineParameterType = this.defineParameterType.bind(this); 70 | this.defineBefore = this.defineBefore.bind(this); 71 | this.defineAfter = this.defineAfter.bind(this); 72 | 73 | this.parameterTypeRegistry = new ParameterTypeRegistry(); 74 | } 75 | 76 | public finalize() { 77 | for (const { description, implementation } of this 78 | .preliminaryStepDefinitions) { 79 | if (typeof description === "string") { 80 | this.stepDefinitions.push({ 81 | expression: new CucumberExpression( 82 | description, 83 | this.parameterTypeRegistry 84 | ), 85 | implementation, 86 | }); 87 | } else { 88 | this.stepDefinitions.push({ 89 | expression: new RegularExpression( 90 | description, 91 | this.parameterTypeRegistry 92 | ), 93 | implementation, 94 | }); 95 | } 96 | } 97 | } 98 | 99 | public defineStep(description: string | RegExp, implementation: () => void) { 100 | if (typeof description !== "string" && !(description instanceof RegExp)) { 101 | throw new Error("Unexpected argument for step definition"); 102 | } 103 | 104 | this.preliminaryStepDefinitions.push({ 105 | description, 106 | implementation, 107 | }); 108 | } 109 | 110 | public defineParameterType({ 111 | name, 112 | regexp, 113 | transformer, 114 | }: IParameterTypeDefinition) { 115 | this.parameterTypeRegistry.defineParameterType( 116 | new ParameterType(name, regexp, null, transformer, true, false) 117 | ); 118 | } 119 | 120 | public defineBefore(options: { tags?: string }, fn: IHookBody) { 121 | this.beforeHooks.push(parseHookArguments(options, fn, "Before")); 122 | } 123 | 124 | public defineAfter(options: { tags?: string }, fn: IHookBody) { 125 | this.afterHooks.push(parseHookArguments(options, fn, "After")); 126 | } 127 | 128 | private resolveStepDefintion(text: string) { 129 | const matchingStepDefinitions = this.stepDefinitions.filter( 130 | (stepDefinition) => stepDefinition.expression.match(text) 131 | ); 132 | 133 | if (matchingStepDefinitions.length === 0) { 134 | throw new Error(`Step implementation missing for: ${text}`); 135 | } else if (matchingStepDefinitions.length > 1) { 136 | throw new Error( 137 | `Multiple matching step definitions for: ${text}\n` + 138 | matchingStepDefinitions 139 | .map((stepDefinition) => { 140 | const { expression } = stepDefinition; 141 | if (expression instanceof RegularExpression) { 142 | return ` ${expression.regexp}`; 143 | } else if (expression instanceof CucumberExpression) { 144 | return ` ${expression.source}`; 145 | } 146 | }) 147 | .join("\n") 148 | ); 149 | } else { 150 | return matchingStepDefinitions[0]; 151 | } 152 | } 153 | 154 | public runStepDefininition( 155 | world: Mocha.Context, 156 | text: string, 157 | argument?: DataTable | string 158 | ): any { 159 | const stepDefinition = this.resolveStepDefintion(text); 160 | 161 | const args = stepDefinition.expression 162 | .match(text)! 163 | .map((match) => match.getValue(world)); 164 | 165 | if (argument) { 166 | args.push(argument); 167 | } 168 | 169 | return stepDefinition.implementation.apply(world, args); 170 | } 171 | 172 | public resolveBeforeHooks(tags: string[]) { 173 | return this.beforeHooks.filter((beforeHook) => 174 | beforeHook.node.evaluate(tags) 175 | ); 176 | } 177 | 178 | public resolveAfterHooks(tags: string[]) { 179 | return this.afterHooks.filter((beforeHook) => 180 | beforeHook.node.evaluate(tags) 181 | ); 182 | } 183 | 184 | public runHook(world: Mocha.Context, hook: IHook) { 185 | hook.implementation.call(world); 186 | } 187 | } 188 | 189 | declare global { 190 | namespace globalThis { 191 | var __cypress_cucumber_preprocessor_registry_dont_use_this: 192 | | Registry 193 | | undefined; 194 | } 195 | } 196 | 197 | const globalPropertyName = 198 | "__cypress_cucumber_preprocessor_registry_dont_use_this"; 199 | 200 | export function withRegistry(fn: () => void): Registry { 201 | const registry = new Registry(); 202 | assignRegistry(registry); 203 | fn(); 204 | freeRegistry(); 205 | return registry; 206 | } 207 | 208 | export function assignRegistry(registry: Registry) { 209 | globalThis[globalPropertyName] = registry; 210 | } 211 | 212 | export function freeRegistry() { 213 | delete globalThis[globalPropertyName]; 214 | } 215 | 216 | export function getRegistry() { 217 | return assertAndReturn( 218 | globalThis[globalPropertyName], 219 | "Expected to find a global registry (this usually means you are trying to define steps or hooks in support/e2e.js, which is not supported)" 220 | ); 221 | } 222 | -------------------------------------------------------------------------------- /lib/step-definitions.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import glob from "glob"; 4 | 5 | import util from "util"; 6 | 7 | import assert from "assert"; 8 | 9 | import isPathInside from "is-path-inside"; 10 | 11 | import { ICypressConfiguration } from "@klaveness/cypress-configuration"; 12 | 13 | import debug from "./debug"; 14 | 15 | import { 16 | DEFAULT_POST_10_STEP_DEFINITIONS, 17 | DEFAULT_PRE_10_STEP_DEFINITIONS, 18 | IPreprocessorConfiguration, 19 | } from "./preprocessor-configuration"; 20 | import { 21 | ICypressPost10Configuration, 22 | ICypressPre10Configuration, 23 | } from "@klaveness/cypress-configuration/lib/cypress-configuration"; 24 | 25 | import { ensureIsAbsolute, ensureIsRelative } from "./helpers"; 26 | 27 | export async function getStepDefinitionPaths( 28 | configuration: { 29 | cypress: ICypressConfiguration; 30 | preprocessor: IPreprocessorConfiguration; 31 | }, 32 | filepath: string 33 | ): Promise { 34 | const files = ( 35 | await Promise.all( 36 | getStepDefinitionPatterns(configuration, filepath).map((pattern) => 37 | util.promisify(glob)(pattern, { nodir: true }) 38 | ) 39 | ) 40 | ).reduce((acum, el) => acum.concat(el), []); 41 | 42 | if (files.length === 0) { 43 | debug("found no step definitions"); 44 | } else { 45 | debug(`found step definitions ${util.inspect(files)}`); 46 | } 47 | 48 | return files; 49 | } 50 | 51 | function trimFeatureExtension(filepath: string) { 52 | return filepath.replace(/\.feature$/, ""); 53 | } 54 | 55 | export function pathParts(relativePath: string): string[] { 56 | assert( 57 | !path.isAbsolute(relativePath), 58 | `Expected a relative path but got ${relativePath}` 59 | ); 60 | 61 | const parts: string[] = []; 62 | 63 | do { 64 | parts.push(relativePath); 65 | } while ( 66 | (relativePath = path.normalize(path.join(relativePath, ".."))) !== "." 67 | ); 68 | 69 | return parts; 70 | } 71 | 72 | export function getStepDefinitionPatterns( 73 | configuration: { 74 | cypress: ICypressConfiguration; 75 | preprocessor: IPreprocessorConfiguration; 76 | }, 77 | filepath: string 78 | ): string[] { 79 | const { cypress, preprocessor } = configuration; 80 | 81 | if ("specPattern" in cypress) { 82 | return getStepDefinitionPatternsPost10({ cypress, preprocessor }, filepath); 83 | } else { 84 | return getStepDefinitionPatternsPre10({ cypress, preprocessor }, filepath); 85 | } 86 | } 87 | 88 | export function getStepDefinitionPatternsPost10( 89 | configuration: { 90 | cypress: Pick; 91 | preprocessor: IPreprocessorConfiguration; 92 | }, 93 | filepath: string 94 | ): string[] { 95 | const projectRoot = configuration.cypress.projectRoot; 96 | 97 | if (!isPathInside(filepath, projectRoot)) { 98 | throw new Error(`${filepath} is not inside ${projectRoot}`); 99 | } 100 | 101 | debug( 102 | `looking for step definitions using ${util.inspect( 103 | configuration.preprocessor.stepDefinitions 104 | )}` 105 | ); 106 | 107 | const filepathReplacement = trimFeatureExtension( 108 | path.relative(projectRoot, filepath) 109 | ); 110 | 111 | debug(`replacing [filepath] with ${util.inspect(filepathReplacement)}`); 112 | 113 | const parts = pathParts(filepathReplacement); 114 | 115 | debug(`replacing [filepart] with ${util.inspect(parts)}`); 116 | 117 | const stepDefinitions = configuration.preprocessor.stepDefinitions 118 | ? [configuration.preprocessor.stepDefinitions].flat() 119 | : DEFAULT_POST_10_STEP_DEFINITIONS; 120 | 121 | return stepDefinitions 122 | .flatMap((pattern) => { 123 | if (pattern.includes("[filepath]") && pattern.includes("[filepart]")) { 124 | throw new Error( 125 | `Pattern cannot contain both [filepath] and [filepart], but got ${util.inspect( 126 | pattern 127 | )}` 128 | ); 129 | } else if (pattern.includes("[filepath]")) { 130 | return pattern.replace("[filepath]", filepathReplacement); 131 | } else if (pattern.includes("[filepart]")) { 132 | return [ 133 | ...parts.map((part) => pattern.replace("[filepart]", part)), 134 | path.normalize(pattern.replace("[filepart]", ".")), 135 | ]; 136 | } else { 137 | return pattern; 138 | } 139 | }) 140 | .map((pattern) => path.join(projectRoot, pattern)); 141 | } 142 | 143 | export function getStepDefinitionPatternsPre10( 144 | configuration: { 145 | cypress: Pick< 146 | ICypressPre10Configuration, 147 | "projectRoot" | "integrationFolder" 148 | >; 149 | preprocessor: IPreprocessorConfiguration; 150 | }, 151 | filepath: string 152 | ): string[] { 153 | const fullIntegrationFolder = ensureIsAbsolute( 154 | configuration.cypress.projectRoot, 155 | configuration.cypress.integrationFolder 156 | ); 157 | 158 | if (!isPathInside(filepath, fullIntegrationFolder)) { 159 | throw new Error(`${filepath} is not inside ${fullIntegrationFolder}`); 160 | } 161 | 162 | const filepathReplacement = trimFeatureExtension( 163 | path.relative(fullIntegrationFolder, filepath) 164 | ); 165 | 166 | debug(`replacing [filepath] with ${util.inspect(filepathReplacement)}`); 167 | 168 | const parts = pathParts(filepathReplacement); 169 | 170 | debug(`replacing [filepart] with ${util.inspect(parts)}`); 171 | 172 | const stepDefinitions = configuration.preprocessor.stepDefinitions 173 | ? [configuration.preprocessor.stepDefinitions].flat() 174 | : DEFAULT_PRE_10_STEP_DEFINITIONS.map((pattern) => 175 | pattern.replace( 176 | "[integration-directory]", 177 | ensureIsRelative( 178 | configuration.cypress.projectRoot, 179 | configuration.cypress.integrationFolder 180 | ) 181 | ) 182 | ); 183 | 184 | debug(`looking for step definitions using ${util.inspect(stepDefinitions)}`); 185 | 186 | return stepDefinitions 187 | .flatMap((pattern) => { 188 | if (pattern.includes("[filepath]") && pattern.includes("[filepart]")) { 189 | throw new Error( 190 | `Pattern cannot contain both [filepath] and [filepart], but got ${util.inspect( 191 | pattern 192 | )}` 193 | ); 194 | } else if (pattern.includes("[filepath]")) { 195 | return pattern.replace("[filepath]", filepathReplacement); 196 | } else if (pattern.includes("[filepart]")) { 197 | return [ 198 | ...parts.map((part) => pattern.replace("[filepart]", part)), 199 | path.normalize(pattern.replace("[filepart]", ".")), 200 | ]; 201 | } else { 202 | return pattern; 203 | } 204 | }) 205 | .map((pattern) => path.join(configuration.cypress.projectRoot, pattern)); 206 | } 207 | -------------------------------------------------------------------------------- /lib/step-definitions.test.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | 3 | import assert from "assert"; 4 | 5 | import { 6 | ICypressPost10Configuration, 7 | ICypressPre10Configuration, 8 | } from "@klaveness/cypress-configuration"; 9 | 10 | import { 11 | IPreprocessorConfiguration, 12 | PreprocessorConfiguration, 13 | } from "./preprocessor-configuration"; 14 | 15 | import { 16 | getStepDefinitionPatternsPost10, 17 | getStepDefinitionPatternsPre10, 18 | pathParts, 19 | } from "./step-definitions"; 20 | 21 | function pre10example( 22 | filepath: string, 23 | cypressConfiguration: Pick< 24 | ICypressPre10Configuration, 25 | "projectRoot" | "integrationFolder" 26 | >, 27 | preprocessorConfiguration: Partial, 28 | expected: string[] 29 | ) { 30 | it(`should return [${expected.join( 31 | ", " 32 | )}] for ${filepath} with ${util.inspect(preprocessorConfiguration)} in ${ 33 | cypressConfiguration.projectRoot 34 | }`, () => { 35 | const actual = getStepDefinitionPatternsPre10( 36 | { 37 | cypress: cypressConfiguration, 38 | preprocessor: new PreprocessorConfiguration( 39 | preprocessorConfiguration, 40 | {} 41 | ), 42 | }, 43 | filepath 44 | ); 45 | 46 | const throwUnequal = () => { 47 | throw new Error( 48 | `Expected ${util.inspect(expected)}, but got ${util.inspect(actual)}` 49 | ); 50 | }; 51 | 52 | if (expected.length !== actual.length) { 53 | throwUnequal(); 54 | } 55 | 56 | for (let i = 0; i < expected.length; i++) { 57 | if (expected[i] !== actual[i]) { 58 | throwUnequal(); 59 | } 60 | } 61 | }); 62 | } 63 | 64 | function post10example( 65 | filepath: string, 66 | cypressConfiguration: Pick, 67 | preprocessorConfiguration: Partial, 68 | expected: string[] 69 | ) { 70 | it(`should return [${expected.join( 71 | ", " 72 | )}] for ${filepath} with ${util.inspect(preprocessorConfiguration)} in ${ 73 | cypressConfiguration.projectRoot 74 | }`, () => { 75 | const actual = getStepDefinitionPatternsPost10( 76 | { 77 | cypress: cypressConfiguration, 78 | preprocessor: new PreprocessorConfiguration( 79 | preprocessorConfiguration, 80 | {} 81 | ), 82 | }, 83 | filepath 84 | ); 85 | 86 | const throwUnequal = () => { 87 | throw new Error( 88 | `Expected ${util.inspect(expected)}, but got ${util.inspect(actual)}` 89 | ); 90 | }; 91 | 92 | if (expected.length !== actual.length) { 93 | throwUnequal(); 94 | } 95 | 96 | for (let i = 0; i < expected.length; i++) { 97 | if (expected[i] !== actual[i]) { 98 | throwUnequal(); 99 | } 100 | } 101 | }); 102 | } 103 | 104 | describe("pathParts()", () => { 105 | const relativePath = "foo/bar/baz"; 106 | const expectedParts = ["foo/bar/baz", "foo/bar", "foo"]; 107 | 108 | it(`should return ${util.inspect(expectedParts)} for ${util.inspect( 109 | relativePath 110 | )}`, () => { 111 | assert.deepStrictEqual(pathParts(relativePath), expectedParts); 112 | }); 113 | }); 114 | 115 | describe("getStepDefinitionPatternsPre10()", () => { 116 | pre10example( 117 | "/foo/bar/cypress/integration/baz.feature", 118 | { 119 | projectRoot: "/foo/bar", 120 | integrationFolder: "cypress/integration", 121 | }, 122 | {}, 123 | [ 124 | "/foo/bar/cypress/integration/baz/**/*.{js,mjs,ts}", 125 | "/foo/bar/cypress/integration/baz.{js,mjs,ts}", 126 | "/foo/bar/cypress/support/step_definitions/**/*.{js,mjs,ts}", 127 | ] 128 | ); 129 | 130 | pre10example( 131 | "/cypress/integration/foo/bar/baz.feature", 132 | { 133 | projectRoot: "/", 134 | integrationFolder: "cypress/integration", 135 | }, 136 | { 137 | stepDefinitions: "cypress/integration/[filepath]/step_definitions/*.ts", 138 | }, 139 | ["/cypress/integration/foo/bar/baz/step_definitions/*.ts"] 140 | ); 141 | 142 | pre10example( 143 | "/cypress/integration/foo/bar/baz.feature", 144 | { 145 | projectRoot: "/", 146 | integrationFolder: "cypress/integration", 147 | }, 148 | { 149 | stepDefinitions: "cypress/integration/[filepart]/step_definitions/*.ts", 150 | }, 151 | [ 152 | "/cypress/integration/foo/bar/baz/step_definitions/*.ts", 153 | "/cypress/integration/foo/bar/step_definitions/*.ts", 154 | "/cypress/integration/foo/step_definitions/*.ts", 155 | "/cypress/integration/step_definitions/*.ts", 156 | ] 157 | ); 158 | 159 | it("should error when provided a path not within integrationFolder", () => { 160 | assert.throws(() => { 161 | getStepDefinitionPatternsPre10( 162 | { 163 | cypress: { 164 | projectRoot: "/foo/bar", 165 | integrationFolder: "cypress/integration", 166 | }, 167 | preprocessor: { 168 | stepDefinitions: [], 169 | }, 170 | }, 171 | "/foo/bar/cypress/features/baz.feature" 172 | ); 173 | }, "/foo/bar/cypress/features/baz.feature is not within cypress/integration"); 174 | }); 175 | 176 | it("should error when provided a path not within cwd", () => { 177 | assert.throws(() => { 178 | getStepDefinitionPatternsPre10( 179 | { 180 | cypress: { 181 | projectRoot: "/baz", 182 | integrationFolder: "cypress/integration", 183 | }, 184 | preprocessor: { 185 | stepDefinitions: [], 186 | }, 187 | }, 188 | "/foo/bar/cypress/integration/baz.feature" 189 | ); 190 | }, "/foo/bar/cypress/features/baz.feature is not within /baz"); 191 | }); 192 | }); 193 | 194 | describe("getStepDefinitionPatternsPost10()", () => { 195 | post10example( 196 | "/foo/bar/cypress/e2e/baz.feature", 197 | { 198 | projectRoot: "/foo/bar", 199 | }, 200 | {}, 201 | [ 202 | "/foo/bar/cypress/e2e/baz/**/*.{js,mjs,ts}", 203 | "/foo/bar/cypress/e2e/baz.{js,mjs,ts}", 204 | "/foo/bar/cypress/support/step_definitions/**/*.{js,mjs,ts}", 205 | ] 206 | ); 207 | 208 | post10example( 209 | "/cypress/e2e/foo/bar/baz.feature", 210 | { 211 | projectRoot: "/", 212 | }, 213 | { 214 | stepDefinitions: "[filepath]/step_definitions/*.ts", 215 | }, 216 | ["/cypress/e2e/foo/bar/baz/step_definitions/*.ts"] 217 | ); 218 | 219 | post10example( 220 | "/cypress/e2e/foo/bar/baz.feature", 221 | { 222 | projectRoot: "/", 223 | }, 224 | { 225 | stepDefinitions: "[filepart]/step_definitions/*.ts", 226 | }, 227 | [ 228 | "/cypress/e2e/foo/bar/baz/step_definitions/*.ts", 229 | "/cypress/e2e/foo/bar/step_definitions/*.ts", 230 | "/cypress/e2e/foo/step_definitions/*.ts", 231 | "/cypress/e2e/step_definitions/*.ts", 232 | "/cypress/step_definitions/*.ts", 233 | "/step_definitions/*.ts", 234 | ] 235 | ); 236 | 237 | it("should error when provided a path not within cwd", () => { 238 | assert.throws(() => { 239 | getStepDefinitionPatternsPost10( 240 | { 241 | cypress: { 242 | projectRoot: "/baz", 243 | }, 244 | preprocessor: { 245 | stepDefinitions: [], 246 | }, 247 | }, 248 | "/foo/bar/cypress/e2e/baz.feature" 249 | ); 250 | }, "/foo/bar/cypress/features/baz.feature is not within /baz"); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /lib/add-cucumber-preprocessor-plugin.ts: -------------------------------------------------------------------------------- 1 | import syncFs, { promises as fs, constants as fsConstants } from "fs"; 2 | 3 | import path from "path"; 4 | 5 | import child_process from "child_process"; 6 | 7 | import chalk from "chalk"; 8 | 9 | import { messages } from "@cucumber/messages"; 10 | 11 | import parse from "@cucumber/tag-expressions"; 12 | 13 | import { generateMessages } from "@cucumber/gherkin"; 14 | 15 | import { IdGenerator } from "@cucumber/messages"; 16 | 17 | import { 18 | getTestFiles, 19 | ICypressConfiguration, 20 | } from "@klaveness/cypress-configuration"; 21 | 22 | import { 23 | HOOK_FAILURE_EXPR, 24 | TASK_APPEND_MESSAGES, 25 | TASK_CREATE_STRING_ATTACHMENT, 26 | TASK_TEST_STEP_STARTED, 27 | } from "./constants"; 28 | 29 | import { resolve as origResolve } from "./preprocessor-configuration"; 30 | 31 | import { notNull } from "./type-guards"; 32 | 33 | import { getTags } from "./environment-helpers"; 34 | 35 | import { ensureIsAbsolute } from "./helpers"; 36 | 37 | function memoize any>( 38 | fn: T 39 | ): (...args: Parameters) => ReturnType { 40 | let result: ReturnType; 41 | 42 | return (...args: Parameters) => { 43 | if (result) { 44 | return result; 45 | } 46 | 47 | return (result = fn(...args)); 48 | }; 49 | } 50 | 51 | const resolve = memoize(origResolve); 52 | 53 | let currentTestStepStartedId: string; 54 | let currentSpecMessages: messages.IEnvelope[]; 55 | 56 | export async function beforeRunHandler(config: Cypress.PluginConfigOptions) { 57 | const preprocessor = await resolve(config.projectRoot, config.env); 58 | 59 | if (!preprocessor.messages.enabled) { 60 | return; 61 | } 62 | 63 | const messagesPath = ensureIsAbsolute( 64 | config.projectRoot, 65 | preprocessor.messages.output 66 | ); 67 | 68 | await fs.rm(messagesPath, { force: true }); 69 | } 70 | 71 | export async function afterRunHandler(config: Cypress.PluginConfigOptions) { 72 | const preprocessor = await resolve(config.projectRoot, config.env); 73 | 74 | if (!preprocessor.json.enabled) { 75 | return; 76 | } 77 | 78 | const messagesPath = ensureIsAbsolute( 79 | config.projectRoot, 80 | preprocessor.messages.output 81 | ); 82 | 83 | const jsonPath = ensureIsAbsolute( 84 | config.projectRoot, 85 | preprocessor.json.output 86 | ); 87 | 88 | try { 89 | await fs.access(messagesPath, fsConstants.F_OK); 90 | } catch { 91 | return; 92 | } 93 | 94 | await fs.mkdir(path.dirname(jsonPath), { recursive: true }); 95 | 96 | const messages = await fs.open(messagesPath, "r"); 97 | 98 | try { 99 | const json = await fs.open(jsonPath, "w"); 100 | 101 | try { 102 | const child = child_process.spawn(preprocessor.json.formatter, { 103 | stdio: [messages.fd, json.fd, "inherit"], 104 | }); 105 | 106 | await new Promise((resolve, reject) => { 107 | child.on("exit", (code) => { 108 | if (code === 0) { 109 | resolve(); 110 | } else { 111 | reject( 112 | new Error( 113 | `${preprocessor.json.formatter} exited non-successfully` 114 | ) 115 | ); 116 | } 117 | }); 118 | 119 | child.on("error", reject); 120 | }); 121 | } finally { 122 | await json.close(); 123 | } 124 | } finally { 125 | await messages.close(); 126 | } 127 | } 128 | 129 | export async function beforeSpecHandler(config: Cypress.PluginConfigOptions) { 130 | currentSpecMessages = []; 131 | } 132 | 133 | export async function afterSpecHandler( 134 | config: Cypress.PluginConfigOptions, 135 | spec: Cypress.Spec, 136 | results: CypressCommandLine.RunResult 137 | ) { 138 | const preprocessor = await resolve(config.projectRoot, config.env); 139 | 140 | const messagesPath = ensureIsAbsolute( 141 | config.projectRoot, 142 | preprocessor.messages.output 143 | ); 144 | 145 | // `results` is undefined when running via `cypress open`. 146 | if (!preprocessor.messages.enabled || !currentSpecMessages || !results) { 147 | return; 148 | } 149 | 150 | const wasRemainingSkipped = results.tests.some((test) => 151 | test.displayError?.match(HOOK_FAILURE_EXPR) 152 | ); 153 | 154 | if (wasRemainingSkipped) { 155 | console.log( 156 | chalk.yellow( 157 | ` Hook failures can't be represented in JSON reports, thus none is created for ${spec.relative}.` 158 | ) 159 | ); 160 | } else { 161 | await fs.mkdir(path.dirname(messagesPath), { recursive: true }); 162 | 163 | await fs.writeFile( 164 | messagesPath, 165 | currentSpecMessages.map((message) => JSON.stringify(message)).join("\n") + 166 | "\n", 167 | { 168 | flag: "a", 169 | } 170 | ); 171 | } 172 | } 173 | 174 | export async function afterScreenshotHandler( 175 | config: Cypress.PluginConfigOptions, 176 | details: Cypress.ScreenshotDetails 177 | ) { 178 | const preprocessor = await resolve(config.projectRoot, config.env); 179 | 180 | if (!preprocessor.messages.enabled || !currentSpecMessages) { 181 | return details; 182 | } 183 | 184 | let buffer; 185 | 186 | try { 187 | buffer = await fs.readFile(details.path); 188 | } catch { 189 | return details; 190 | } 191 | 192 | const message: messages.IEnvelope = { 193 | attachment: { 194 | testStepId: currentTestStepStartedId, 195 | body: buffer.toString("base64"), 196 | mediaType: "image/png", 197 | contentEncoding: 198 | "BASE64" as unknown as messages.Attachment.ContentEncoding, 199 | }, 200 | }; 201 | 202 | currentSpecMessages.push(message); 203 | 204 | return details; 205 | } 206 | 207 | type AddOptions = { 208 | omitBeforeRunHandler?: boolean; 209 | omitAfterRunHandler?: boolean; 210 | omitBeforeSpecHandler?: boolean; 211 | omitAfterSpecHandler?: boolean; 212 | omitAfterScreenshotHandler?: boolean; 213 | }; 214 | 215 | export default async function addCucumberPreprocessorPlugin( 216 | on: Cypress.PluginEvents, 217 | config: Cypress.PluginConfigOptions, 218 | options: AddOptions = {} 219 | ) { 220 | const preprocessor = await resolve(config.projectRoot, config.env); 221 | 222 | if (!options.omitBeforeRunHandler) { 223 | on("before:run", () => beforeRunHandler(config)); 224 | } 225 | 226 | if (!options.omitAfterRunHandler) { 227 | on("after:run", () => afterRunHandler(config)); 228 | } 229 | 230 | if (!options.omitBeforeSpecHandler) { 231 | on("before:spec", () => beforeSpecHandler(config)); 232 | } 233 | 234 | if (!options.omitAfterSpecHandler) { 235 | on("after:spec", (spec, results) => 236 | afterSpecHandler(config, spec, results) 237 | ); 238 | } 239 | 240 | if (!options.omitAfterScreenshotHandler) { 241 | on("after:screenshot", (details) => 242 | afterScreenshotHandler(config, details) 243 | ); 244 | } 245 | 246 | on("task", { 247 | [TASK_APPEND_MESSAGES]: (messages: messages.IEnvelope[]) => { 248 | if (!currentSpecMessages) { 249 | return true; 250 | } 251 | 252 | currentSpecMessages.push(...messages); 253 | 254 | return true; 255 | }, 256 | 257 | [TASK_TEST_STEP_STARTED]: (testStepStartedId) => { 258 | if (!currentSpecMessages) { 259 | return true; 260 | } 261 | 262 | currentTestStepStartedId = testStepStartedId; 263 | 264 | return true; 265 | }, 266 | 267 | [TASK_CREATE_STRING_ATTACHMENT]: ({ data, mediaType, encoding }) => { 268 | if (!currentSpecMessages) { 269 | return true; 270 | } 271 | 272 | const message: messages.IEnvelope = { 273 | attachment: { 274 | testStepId: currentTestStepStartedId, 275 | body: data, 276 | mediaType: mediaType, 277 | contentEncoding: encoding, 278 | }, 279 | }; 280 | 281 | currentSpecMessages.push(message); 282 | 283 | return true; 284 | }, 285 | }); 286 | 287 | const tags = getTags(config.env); 288 | 289 | if (tags !== null && preprocessor.filterSpecs) { 290 | const node = parse(tags); 291 | 292 | const propertyName = "specPattern" in config ? "specPattern" : "testFiles"; 293 | 294 | (config as any)[propertyName] = getTestFiles( 295 | config as unknown as ICypressConfiguration 296 | ).filter((testFile) => { 297 | const content = syncFs.readFileSync(testFile).toString("utf-8"); 298 | 299 | const options = { 300 | includeSource: false, 301 | includeGherkinDocument: false, 302 | includePickles: true, 303 | newId: IdGenerator.incrementing(), 304 | }; 305 | 306 | const envelopes = generateMessages(content, testFile, options); 307 | 308 | const pickles = envelopes 309 | .map((envelope) => envelope.pickle) 310 | .filter(notNull); 311 | 312 | return pickles.some((pickle) => 313 | node.evaluate(pickle.tags?.map((tag) => tag.name).filter(notNull) ?? []) 314 | ); 315 | }); 316 | } 317 | 318 | return config; 319 | } 320 | --------------------------------------------------------------------------------