Repository: TheBrainFamily/cypress-cucumber-preprocessor
Pull request #385 (2020-06-01 01:21:38)

feat: add support for custom step timeout
Add support for passing in a config param, matching the signature of cucumber-js, to set the step
timeout in cypress.

e.g.
```
Given('a long running step', { timeout: 60000 }, () => { ... });
```
It is possible to do this by calling `cy.then({timeout: 60000}, () => {...})` inside the implementation function, but that's inconsistent with [the signature for cucumberjs](https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/timeouts.md#timeouts), which feels more intuitive.



Patch:
diff --git a/lib/cukejson/cucumberDataCollector.js b/lib/cukejson/cucumberDataCollector.js
index b494d38..002621a 100644
--- a/lib/cukejson/cucumberDataCollector.js
+++ b/lib/cukejson/cucumberDataCollector.js
@@ -72,6 +72,14 @@ class CucumberDataCollector {
           status: statuses.UNDEFINED,
           duration: this.timeTaken()
         };
+      } else if (err.constructor.name === "Pending") {
+        // cypress marks skipped mocha tests as pending
+        // https://github.com/cypress-io/cypress/issues/3092
+        // don't record this error and mark the step as skipped
+        this.stepResults[this.currentStep] = {
+          status: statuses.SKIPPED,
+          duration: this.timeTaken()
+        };
       } else {
         this.stepResults[this.currentStep] = {
           status: statuses.FAILED,
@@ -122,9 +130,13 @@ class CucumberDataCollector {
       });
     };
     this.recordScenarioResult = scenario => {
-      this.runTests[scenario.name].result = this.anyStepsHaveFailed(scenario)
-        ? statuses.FAILED
-        : statuses.PASSED;
+      const allSkipped = this.areAllStepsSkipped(scenario.name);
+      const anyFailed = this.anyStepsHaveFailed(scenario.name);
+      if (allSkipped) this.runTests[scenario.name].result = statuses.SKIPPED;
+      else
+        this.runTests[scenario.name].result = anyFailed
+          ? statuses.FAILED
+          : statuses.PASSED;
     };
 
     this.setStepToPending = step => {
@@ -138,8 +150,11 @@ class CucumberDataCollector {
       };
     };
 
-    this.anyStepsHaveFailed = () =>
-      Object.values(this.stepResults).find(e => e.status !== statuses.PASSED);
+    this.areAllStepsSkipped = name =>
+      this.runTests[name].every(e => e.status === statuses.SKIPPED);
+
+    this.anyStepsHaveFailed = name =>
+      this.runTests[name].find(e => e.status === statuses.FAILED) !== undefined;
   }
 }
 
diff --git a/lib/resolveStepDefinition.js b/lib/resolveStepDefinition.js
index 83ef4f3..cb60207 100644
--- a/lib/resolveStepDefinition.js
+++ b/lib/resolveStepDefinition.js
@@ -18,7 +18,15 @@ class StepDefinitionRegistry {
     };
 
     this.definitions = [];
-    this.runtime = (matcher, implementation) => {
+    this.runtime = (...args) => {
+      let matcher;
+      let config;
+      let implementation;
+      if (args.length > 2) {
+        [matcher, config, implementation] = args;
+      } else {
+        [matcher, implementation] = args;
+      }
       let expression;
       if (matcher instanceof RegExp) {
         expression = new RegularExpression(
@@ -35,6 +43,7 @@ class StepDefinitionRegistry {
       this.definitions.push({
         implementation,
         expression,
+        config,
         featureName: window.currentFeatureName || "___GLOBAL_EXECUTION___"
       });
     };
@@ -178,6 +187,9 @@ function parseHookArgs(args) {
 }
 
 module.exports = {
+  resolveStepDefinition(step, featureName) {
+    return resolveStepDefinition(step, featureName);
+  },
   resolveAndRunBeforeHooks(scenarioTags, featureName) {
     return resolveAndRunHooks(beforeHookRegistry, scenarioTags, featureName);
   },
@@ -210,20 +222,20 @@ module.exports = {
     }
     throw new Error(`Step implementation missing for: ${stepText}`);
   },
-  given: (expression, implementation) => {
-    stepDefinitionRegistry.runtime(expression, implementation);
+  given: (...args) => {
+    stepDefinitionRegistry.runtime(...args);
   },
-  when: (expression, implementation) => {
-    stepDefinitionRegistry.runtime(expression, implementation);
+  when: (...args) => {
+    stepDefinitionRegistry.runtime(...args);
   },
-  then: (expression, implementation) => {
-    stepDefinitionRegistry.runtime(expression, implementation);
+  then: (...args) => {
+    stepDefinitionRegistry.runtime(...args);
   },
-  and: (expression, implementation) => {
-    stepDefinitionRegistry.runtime(expression, implementation);
+  and: (...args) => {
+    stepDefinitionRegistry.runtime(...args);
   },
-  but: (expression, implementation) => {
-    stepDefinitionRegistry.runtime(expression, implementation);
+  but: (...args) => {
+    stepDefinitionRegistry.runtime(...args);
   },
   Before: (...args) => {
     const { tags, implementation } = parseHookArgs(args);
diff --git a/steps/index.d.ts b/steps/index.d.ts
new file mode 100644
index 0000000..6ba7f82
--- /dev/null
+++ b/steps/index.d.ts
@@ -0,0 +1,105 @@
+export function given(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function when(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function then(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function and(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function but(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function given(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function when(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function then(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function and(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function but(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function defineStep(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function defineParameterType(): void;
+
+// Aliased versions of the above funcs.
+export function Given(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function When(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function Then(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function And(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function But(
+  expression: RegExp | string,
+  implementation: (...args: any[]) => void
+): void;
+export function Given(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function When(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function Then(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function And(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function But(
+  expression: RegExp | string,
+  config: { timeout?: number },
+  implementation: (...args: any[]) => void
+): void;
+export function Before(
+  optionsOrImplementation: object | ((...args: any[]) => void),
+  implementation?: (...args: any[]) => void
+): void;
+export function After(
+  optionsOrImplementation: object | ((...args: any[]) => void),
+  implementation?: (...args: any[]) => void
+): void;


Test patch:
diff --git a/lib/createTestFromScenario.js b/lib/createTestFromScenario.js
index c30a78d..0be1e41 100644
--- a/lib/createTestFromScenario.js
+++ b/lib/createTestFromScenario.js
@@ -1,6 +1,7 @@
 /* eslint-disable prefer-template */
 const statuses = require("cucumber/lib/status").default;
 const {
+  resolveStepDefinition,
   resolveAndRunStepDefinition,
   resolveAndRunBeforeHooks,
   resolveAndRunAfterHooks
@@ -15,8 +16,13 @@ const replaceParameterTags = (rowData, text) =>
 
 // eslint-disable-next-line func-names
 const stepTest = function(state, stepDetails, exampleRowData) {
+  const step = resolveStepDefinition.call(
+    this,
+    stepDetails,
+    state.feature.name
+  );
   cy.then(() => state.onStartStep(stepDetails))
-    .then(() =>
+    .then((step && step.config) || {}, () =>
       resolveAndRunStepDefinition.call(
         this,
         stepDetails,
@@ -33,22 +39,42 @@ const runTest = (scenario, stepsToRun, rowData) => {
     Object.assign({}, step, { index })
   );
 
-  // eslint-disable-next-line func-names
-  it(scenario.name, function() {
-    const state = window.testState;
-    return cy
-      .then(() => state.onStartScenario(scenario, indexedSteps))
-      .then(() =>
-        resolveAndRunBeforeHooks.call(this, scenario.tags, state.feature.name)
-      )
-      .then(() =>
-        indexedSteps.forEach(step => stepTest.call(this, state, step, rowData))
-      )
-      .then(() =>
-        resolveAndRunAfterHooks.call(this, scenario.tags, state.feature.name)
-      )
-      .then(() => state.onFinishScenario(scenario));
-  });
+  // should we actually run this scenario
+  // or just mark it as skipped
+  if (scenario.shouldRun) {
+    // eslint-disable-next-line func-names
+    it(scenario.name, function() {
+      const state = window.testState;
+      return cy
+        .then(() => state.onStartScenario(scenario, indexedSteps))
+        .then(() =>
+          resolveAndRunBeforeHooks.call(this, scenario.tags, state.feature.name)
+        )
+        .then(() =>
+          indexedSteps.forEach(step =>
+            stepTest.call(this, state, step, rowData)
+          )
+        )
+        .then(() =>
+          resolveAndRunAfterHooks.call(this, scenario.tags, state.feature.name)
+        )
+        .then(() => state.onFinishScenario(scenario));
+    });
+  } else {
+    // eslint-disable-next-line func-names,prefer-arrow-callback
+    it(scenario.name, function() {
+      // register this scenario with the cucumber data collector
+      // but don't run it
+      // Tell mocha this is a skipped test so it also shows correctly in Cypress
+      const state = window.testState;
+      cy.then(() => state.onStartScenario(scenario, indexedSteps))
+        .then(() => state.onFinishScenario(scenario))
+        // eslint-disable-next-line func-names
+        .then(function() {
+          return this.skip();
+        });
+    });
+  }
 };
 
 const cleanupFilename = s => s.split(".")[0];
@@ -64,7 +90,7 @@ const writeCucumberJsonFile = json => {
 };
 
 const createTestFromScenarios = (
-  scenariosToRun,
+  allScenarios,
   backgroundSection,
   testState
 ) => {
@@ -87,7 +113,7 @@ const createTestFromScenarios = (
     Cypress.on("fail", failHandler);
   });
 
-  scenariosToRun.forEach(section => {
+  allScenarios.forEach(section => {
     if (section.examples) {
       section.examples.forEach(example => {
         const exampleValues = [];
diff --git a/lib/createTestsFromFeature.js b/lib/createTestsFromFeature.js
index a5bc69a..caa4272 100644
--- a/lib/createTestsFromFeature.js
+++ b/lib/createTestsFromFeature.js
@@ -6,58 +6,37 @@ const createTestsFromFeature = (filePath, spec) => {
   const testState = new CucumberDataCollector(filePath, spec);
   const featureTags = testState.feature.tags;
   const hasEnvTags = !!getEnvTags();
-  const sectionsWithTags = testState.feature.children.filter(
-    section => section.tags && section.tags.length
+  const anyFocused =
+    testState.feature.children.filter(
+      section => section.tags && section.tags.find(t => t.name === "@focus")
+    ).length > 0;
+  const backgroundSection = testState.feature.children.find(
+    section => section.type === "Background"
+  );
+  const allScenarios = testState.feature.children.filter(
+    section => section.type !== "Background"
   );
 
-  const sectionsWithTagsExist = sectionsWithTags.length > 0;
-
-  let everythingShouldRun = false;
-  let featureShouldRun = false;
-  let taggedScenarioShouldRun = false;
-  let anyFocused = false;
-  if (hasEnvTags) {
-    featureShouldRun = shouldProceedCurrentStep(featureTags);
-    taggedScenarioShouldRun = testState.feature.children.some(
-      section =>
-        section.tags &&
-        section.tags.length &&
-        shouldProceedCurrentStep(section.tags.concat(featureTags))
-    );
-  } else if (!sectionsWithTagsExist) {
-    everythingShouldRun = true;
-  } else {
-    anyFocused = sectionsWithTags.some(section =>
-      section.tags.find(t => t.name === "@focus")
-    );
-    if (anyFocused) {
-      taggedScenarioShouldRun = true;
+  const scenariosToRun = allScenarios.filter(section => {
+    let shouldRun;
+    // only just run focused if no env tags set
+    // https://github.com/TheBrainFamily/cypress-cucumber-example#smart-tagging
+    if (!hasEnvTags && anyFocused) {
+      shouldRun = section.tags.find(t => t.name === "@focus");
     } else {
-      everythingShouldRun = true;
+      shouldRun =
+        !hasEnvTags ||
+        shouldProceedCurrentStep(section.tags.concat(featureTags)); // Concat handles inheritance of tags from feature
     }
-  }
-
-  // eslint-disable-next-line prefer-arrow-callback
-  if (everythingShouldRun || featureShouldRun || taggedScenarioShouldRun) {
-    const backgroundSection = testState.feature.children.find(
-      section => section.type === "Background"
-    );
-    const otherSections = testState.feature.children.filter(
-      section => section.type !== "Background"
-    );
-    const scenariosToRun = otherSections.filter(section => {
-      let shouldRun;
-      if (anyFocused) {
-        shouldRun = section.tags.find(t => t.name === "@focus");
-      } else {
-        shouldRun =
-          everythingShouldRun ||
-          shouldProceedCurrentStep(section.tags.concat(featureTags)); // Concat handles inheritance of tags from feature
-      }
-      return shouldRun;
-    });
-    createTestFromScenarios(scenariosToRun, backgroundSection, testState);
-  }
+    return shouldRun;
+  });
+  // create tests for all the scenarios
+  // but flag only the ones that should be run
+  scenariosToRun.forEach(section => {
+    // eslint-disable-next-line no-param-reassign
+    section.shouldRun = true;
+  });
+  createTestFromScenarios(allScenarios, backgroundSection, testState);
 };
 
 module.exports = {
diff --git a/lib/testHelpers/setupTestFramework.js b/lib/testHelpers/setupTestFramework.js
index a636dbb..c14b32d 100644
--- a/lib/testHelpers/setupTestFramework.js
+++ b/lib/testHelpers/setupTestFramework.js
@@ -3,6 +3,7 @@ global.expect = require("chai").expect;
 
 global.before = jest.fn();
 global.after = jest.fn();
+global.skip = jest.fn();
 
 window.Cypress = {
   env: jest.fn(),
@@ -24,9 +25,18 @@ const {
   After
 } = require("../resolveStepDefinition");
 
+const mockedThen = (funcOrConfig, func) => {
+  if (typeof funcOrConfig === "object") {
+    func();
+  } else {
+    funcOrConfig();
+  }
+  return { then: mockedThen };
+};
+
 const mockedPromise = func => {
   func();
-  return { then: mockedPromise };
+  return { then: mockedThen };
 };
 
 window.defineParameterType = defineParameterType;
@@ -46,6 +56,6 @@ window.cy = {
   startStep: mockedPromise,
   finishStep: mockedPromise,
   finishTest: mockedPromise,
-  then: mockedPromise,
+  then: mockedThen,
   end: mockedPromise
 };