From e010deafec230fa12b2aa8edb85ee43b7ed94722 Mon Sep 17 00:00:00 2001 From: Jonas Amundsen Date: Fri, 10 Apr 2020 20:55:20 +0200 Subject: [PATCH] Use pickles to a larger degree This has some benefits, like that we no longer have to construct examples from outlines ourselves and tags are correctly inherited and applied. This also happens to fix #196 and #237. --- lib/createTestFromScenario.js | 90 ++++------------------ lib/createTestsFromFeature.js | 70 +++++------------ lib/cukejson/cucumberDataCollector.js | 34 ++++---- lib/cukejson/cucumberDataCollector.test.js | 37 ++++----- lib/cukejson/generateCucumberJson.js | 18 +++-- lib/featuresLoader.js | 27 ++++++- lib/loader.js | 14 +++- lib/parserHelpers.js | 14 +++- lib/resolveStepDefinition.js | 73 +++--------------- 9 files changed, 133 insertions(+), 244 deletions(-) diff --git a/lib/createTestFromScenario.js b/lib/createTestFromScenario.js index c30a78d..33221e6 100644 --- a/lib/createTestFromScenario.js +++ b/lib/createTestFromScenario.js @@ -7,47 +7,35 @@ const { } = require("./resolveStepDefinition"); const { generateCucumberJson } = require("./cukejson/generateCucumberJson"); -const replaceParameterTags = (rowData, text) => - Object.keys(rowData).reduce( - (value, key) => value.replace(new RegExp(`<${key}>`, "g"), rowData[key]), - text - ); - // eslint-disable-next-line func-names -const stepTest = function(state, stepDetails, exampleRowData) { +const stepTest = function(state, stepDetails) { cy.then(() => state.onStartStep(stepDetails)) .then(() => - resolveAndRunStepDefinition.call( - this, - stepDetails, - replaceParameterTags, - exampleRowData, - state.feature.name - ) + resolveAndRunStepDefinition.call(this, stepDetails, state.feature.name) ) .then(() => state.onFinishStep(stepDetails, statuses.PASSED)); }; -const runTest = (scenario, stepsToRun, rowData) => { - const indexedSteps = stepsToRun.map((step, index) => +const runTest = pickle => { + const indexedSteps = pickle.steps.map((step, index) => Object.assign({}, step, { index }) ); // eslint-disable-next-line func-names - it(scenario.name, function() { + it(pickle.name, function() { const state = window.testState; return cy - .then(() => state.onStartScenario(scenario, indexedSteps)) + .then(() => state.onStartScenario(pickle, indexedSteps)) .then(() => - resolveAndRunBeforeHooks.call(this, scenario.tags, state.feature.name) + resolveAndRunBeforeHooks.call(this, pickle.tags, state.feature.name) ) .then(() => - indexedSteps.forEach(step => stepTest.call(this, state, step, rowData)) + indexedSteps.forEach(step => stepTest.call(this, state, step)) ) .then(() => - resolveAndRunAfterHooks.call(this, scenario.tags, state.feature.name) + resolveAndRunAfterHooks.call(this, pickle.tags, state.feature.name) ) - .then(() => state.onFinishScenario(scenario)); + .then(() => state.onFinishScenario(pickle)); }); }; @@ -63,11 +51,7 @@ const writeCucumberJsonFile = json => { cy.writeFile(outFile, json, { log: false }); }; -const createTestFromScenarios = ( - scenariosToRun, - backgroundSection, - testState -) => { +const createTestFromPickles = (pickles, testState) => { // eslint-disable-next-line func-names, prefer-arrow-callback before(function() { cy.then(() => testState.onStartTest()); @@ -87,54 +71,8 @@ const createTestFromScenarios = ( Cypress.on("fail", failHandler); }); - scenariosToRun.forEach(section => { - if (section.examples) { - section.examples.forEach(example => { - const exampleValues = []; - const exampleLocations = []; - - example.tableBody.forEach((row, rowIndex) => { - exampleLocations[rowIndex] = row.location; - example.tableHeader.cells.forEach((header, headerIndex) => { - exampleValues[rowIndex] = Object.assign( - {}, - exampleValues[rowIndex], - { - [header.value]: row.cells[headerIndex].value - } - ); - }); - }); - - exampleValues.forEach((rowData, index) => { - // eslint-disable-next-line prefer-arrow-callback - const scenarioName = replaceParameterTags(rowData, section.name); - const uniqueScenarioName = `${scenarioName} (example #${index + 1})`; - const exampleSteps = section.steps.map(step => { - const newStep = Object.assign({}, step); - newStep.text = replaceParameterTags(rowData, newStep.text); - return newStep; - }); - - const stepsToRun = backgroundSection - ? backgroundSection.steps.concat(exampleSteps) - : exampleSteps; - - const scenarioExample = Object.assign({}, section, { - name: uniqueScenarioName, - example: exampleLocations[index] - }); - - runTest.call(this, scenarioExample, stepsToRun, rowData); - }); - }); - } else { - const stepsToRun = backgroundSection - ? backgroundSection.steps.concat(section.steps) - : section.steps; - - runTest.call(this, section, stepsToRun); - } + pickles.forEach(pickle => { + runTest.call(this, pickle); }); // eslint-disable-next-line func-names, prefer-arrow-callback @@ -149,5 +87,5 @@ const createTestFromScenarios = ( }; module.exports = { - createTestFromScenarios + createTestFromScenarios: createTestFromPickles }; diff --git a/lib/createTestsFromFeature.js b/lib/createTestsFromFeature.js index 43460e7..e1c5538 100644 --- a/lib/createTestsFromFeature.js +++ b/lib/createTestsFromFeature.js @@ -2,62 +2,30 @@ const { CucumberDataCollector } = require("./cukejson/cucumberDataCollector"); const { createTestFromScenarios } = require("./createTestFromScenario"); const { shouldProceedCurrentStep, getEnvTags } = require("./tagsHelper"); -const createTestsFromFeature = (filePath, source, feature) => { +const flatten = collection => + collection.reduce((acum, element) => [].concat(acum).concat(element)); + +const createTestsFromFeature = (filePath, source, feature, pickles) => { const testState = new CucumberDataCollector(filePath, source, feature); - const featureTags = testState.feature.tags; - const hasEnvTags = !!getEnvTags(); - const sectionsWithTags = testState.feature.children.filter( - section => section.tags && section.tags.length - ); + const envTags = getEnvTags(); - const sectionsWithTagsExist = sectionsWithTags.length > 0; + let tagFilter = null; - 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; - } else { - everythingShouldRun = true; - } - } + const tagsUsedInTests = flatten(pickles.map(pickle => pickle.tags)).map( + tag => tag.name + ); - // 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); + if (tagsUsedInTests.includes("@focus")) { + tagFilter = "@focus"; + } else if (envTags) { + tagFilter = envTags; } + + const picklesToRun = pickles.filter( + pickle => !tagFilter || shouldProceedCurrentStep(pickle.tags, tagFilter) + ); + + createTestFromScenarios(picklesToRun, testState); }; module.exports = { diff --git a/lib/cukejson/cucumberDataCollector.js b/lib/cukejson/cucumberDataCollector.js index 476a601..794ad10 100644 --- a/lib/cukejson/cucumberDataCollector.js +++ b/lib/cukejson/cucumberDataCollector.js @@ -4,7 +4,7 @@ class CucumberDataCollector { constructor(uri, source, feature) { this.feature = feature; this.scenarioSteps = {}; - this.runScenarios = {}; + this.runPickles = {}; this.runTests = {}; this.stepResults = {}; this.testError = null; @@ -33,22 +33,22 @@ class CucumberDataCollector { } }; - this.onStartScenario = (scenario, stepsToRun) => { - this.currentScenario = scenario; + this.onStartScenario = (pickle, stepsToRun) => { + this.currentScenario = pickle; this.currentStep = 0; this.stepResults = {}; - this.scenarioSteps[scenario.name] = stepsToRun; + this.scenarioSteps[pickle.name] = stepsToRun; this.testError = null; stepsToRun.forEach(step => { this.stepResults[step.index] = { status: statuses.PENDING }; }); - this.runScenarios[scenario.name] = scenario; + this.runPickles[pickle.name] = pickle; }; - this.onFinishScenario = scenario => { - this.markStillPendingStepsAsSkipped(scenario); - this.recordScenarioResult(scenario); + this.onFinishScenario = pickle => { + this.markStillPendingStepsAsSkipped(pickle); + this.recordScenarioResult(pickle); }; this.onStartStep = step => { @@ -88,10 +88,12 @@ class CucumberDataCollector { return duration; }; - this.formatTestCase = scenario => { - const line = scenario.example - ? scenario.example.line - : scenario.location.line; + function last(collection) { + return collection[collection.length - 1]; + } + + this.formatTestCase = pickle => { + const { line } = last(pickle.locations); return { sourceLocation: { uri, line } }; @@ -109,8 +111,8 @@ class CucumberDataCollector { }); }; - this.markStillPendingStepsAsSkipped = scenario => { - this.runTests[scenario.name] = Object.keys(this.stepResults).map(key => { + this.markStillPendingStepsAsSkipped = pickle => { + this.runTests[pickle.name] = Object.keys(this.stepResults).map(key => { const result = this.stepResults[key]; return Object.assign({}, result, { status: @@ -120,8 +122,8 @@ class CucumberDataCollector { }); }); }; - this.recordScenarioResult = scenario => { - this.runTests[scenario.name].result = this.anyStepsHaveFailed(scenario) + this.recordScenarioResult = pickle => { + this.runTests[pickle.name].result = this.anyStepsHaveFailed(pickle) ? statuses.FAILED : statuses.PASSED; }; diff --git a/lib/cukejson/cucumberDataCollector.test.js b/lib/cukejson/cucumberDataCollector.test.js index 857fc71..a681166 100644 --- a/lib/cukejson/cucumberDataCollector.test.js +++ b/lib/cukejson/cucumberDataCollector.test.js @@ -32,28 +32,26 @@ const assertCucumberJson = (json, expectedResults) => { expect(json[0].elements[0].steps[2].result.status).to.eql(expectedResults[2]); }; describe("Cucumber Data Collector", () => { - const scenario = { - type: "Scenario", + const pickle = { tags: [], - location: { line: 7, column: 3 }, - keyword: "Scenario", + locations: [{ line: 7, column: 3 }], name: "Basic example", steps: [ { type: "Step", - location: { line: 8, column: 5 }, + locations: [{ line: 8, column: 5 }], keyword: "Given ", text: "a feature and a matching step definition file" }, { type: "Step", - location: { line: 9, column: 5 }, + locations: [{ line: 9, column: 5 }], keyword: "When ", text: "I run cypress tests" }, { type: "Step", - location: { line: 10, column: 5 }, + locations: [{ line: 10, column: 5 }], keyword: "Then ", text: "they run properly" } @@ -62,22 +60,19 @@ describe("Cucumber Data Collector", () => { const stepsToRun = [ { - type: "Step", - location: { line: 8, column: 5 }, + locations: [{ line: 8, column: 5 }], keyword: "Given ", text: "a feature and a matching step definition file", index: 0 }, { - type: "Step", - location: { line: 9, column: 5 }, + locations: [{ line: 9, column: 5 }], keyword: "When ", text: "I run cypress tests", index: 1 }, { - type: "Step", - location: { line: 10, column: 5 }, + locations: [{ line: 10, column: 5 }], keyword: "Then ", text: "they run properly", index: 2 @@ -98,8 +93,8 @@ describe("Cucumber Data Collector", () => { }); it("records pending scenarios", () => { - this.testState.onStartScenario(scenario, stepsToRun); - this.testState.onFinishScenario(scenario); + this.testState.onStartScenario(pickle, stepsToRun); + this.testState.onFinishScenario(pickle); this.testState.onFinishTest(); const json = generateCucumberJson(this.testState); assertCucumberJson(json, [ @@ -109,14 +104,14 @@ describe("Cucumber Data Collector", () => { ]); }); it("records passed scenarios", () => { - this.testState.onStartScenario(scenario, stepsToRun); + this.testState.onStartScenario(pickle, stepsToRun); this.testState.onStartStep(stepsToRun[0]); this.testState.onFinishStep(stepsToRun[0], statuses.PASSED); this.testState.onStartStep(stepsToRun[1]); this.testState.onFinishStep(stepsToRun[1], statuses.PASSED); this.testState.onStartStep(stepsToRun[2]); this.testState.onFinishStep(stepsToRun[2], statuses.PASSED); - this.testState.onFinishScenario(scenario); + this.testState.onFinishScenario(pickle); this.testState.onFinishTest(); const json = generateCucumberJson(this.testState); assertCucumberJson(json, [ @@ -127,12 +122,12 @@ describe("Cucumber Data Collector", () => { }); it("records failed scenarios", () => { - this.testState.onStartScenario(scenario, stepsToRun); + this.testState.onStartScenario(pickle, stepsToRun); this.testState.onStartStep(stepsToRun[0]); this.testState.onFinishStep(stepsToRun[0], statuses.PASSED); this.testState.onStartStep(stepsToRun[1]); this.testState.onFinishStep(stepsToRun[1], statuses.FAILED); - this.testState.onFinishScenario(scenario); + this.testState.onFinishScenario(pickle); this.testState.onFinishTest(); const json = generateCucumberJson(this.testState); assertCucumberJson(json, [ @@ -143,12 +138,12 @@ describe("Cucumber Data Collector", () => { }); it("handles missing steps", () => { - this.testState.onStartScenario(scenario, stepsToRun); + this.testState.onStartScenario(pickle, stepsToRun); this.testState.onStartStep(stepsToRun[0]); this.testState.onFinishStep(stepsToRun[0], statuses.PASSED); this.testState.onStartStep(stepsToRun[1]); this.testState.onFinishStep(stepsToRun[1], statuses.UNDEFINED); - this.testState.onFinishScenario(scenario); + this.testState.onFinishScenario(pickle); this.testState.onFinishTest(); const json = generateCucumberJson(this.testState); assertCucumberJson(json, [ diff --git a/lib/cukejson/generateCucumberJson.js b/lib/cukejson/generateCucumberJson.js index 448b4d5..0e48a48 100644 --- a/lib/cukejson/generateCucumberJson.js +++ b/lib/cukejson/generateCucumberJson.js @@ -3,6 +3,10 @@ const { generateEvents } = require("../parserHelpers"); const JsonFormatter = require("cucumber/lib/formatter/json_formatter").default; const formatterHelpers = require("cucumber/lib/formatter/helpers"); +function last(collection) { + return collection[collection.length - 1]; +} + function generateCucumberJson(state) { let output = ""; const logFn = data => { @@ -35,24 +39,24 @@ function generateCucumberJson(state) { // Feed in the results from the recorded scenarios and steps Object.keys(state.runTests).forEach(test => { - const scenario = state.runScenarios[test]; + const pickle = state.runPickles[test]; const stepResults = state.runTests[test]; const stepsToRun = state.scenarioSteps[test]; const steps = stepsToRun.map(step => ({ - sourceLocation: { uri: state.uri, line: step.location.line } + sourceLocation: { uri: state.uri, line: last(step.locations).line } })); eventBroadcaster.emit("test-case-prepared", { - sourceLocation: state.formatTestCase(scenario).sourceLocation, + sourceLocation: state.formatTestCase(pickle).sourceLocation, steps }); stepResults.forEach((stepResult, stepIdx) => { eventBroadcaster.emit("test-step-prepared", { index: stepIdx, - testCase: state.formatTestCase(scenario) + testCase: state.formatTestCase(pickle) }); eventBroadcaster.emit("test-step-finished", { index: stepIdx, - testCase: state.formatTestCase(scenario), + testCase: state.formatTestCase(pickle), result: stepResult }); if (stepResult.attachment) { @@ -60,8 +64,8 @@ function generateCucumberJson(state) { } }); eventBroadcaster.emit("test-case-finished", { - sourceLocation: state.formatTestCase(scenario).sourceLocation, - result: state.runTests[scenario.name].result + sourceLocation: state.formatTestCase(pickle).sourceLocation, + result: state.runTests[pickle.name].result }); }); eventBroadcaster.emit("test-run-finished", {}); diff --git a/lib/featuresLoader.js b/lib/featuresLoader.js index b00cb00..18217c2 100644 --- a/lib/featuresLoader.js +++ b/lib/featuresLoader.js @@ -15,6 +15,7 @@ const { const createCucumber = ( sources, features, + picklesCol, globalToRequire, nonGlobalToRequire, cucumberJson @@ -53,7 +54,7 @@ const createCucumber = ( createTestsFromFeature('${path.basename(filePath)}', \`${jsStringEscape( source - )}\`, ${JSON.stringify(features[i])}); + )}\`, ${JSON.stringify(features[i])}, ${JSON.stringify(picklesCol[i])}); }) ` ) @@ -95,13 +96,31 @@ module.exports = function(_, filePath = this.resourcePath) { filePath: featurePath })); - const features = sources - .map(({ source }) => parse(source)) - .map(({ feature }) => feature); + /** + * Shamelessly copied from https://gist.github.com/renaudtertrais/25fc5a2e64fe5d0e86894094c6989e10. + */ + function zip(collection) { + if (collection.length === 0) { + return []; + } + + const [first, ...rest] = collection; + + return first.map((el, i) => + rest.reduce((acum, col) => [...acum, col[i]], [el]) + ); + } + + const [features, picklesCol] = zip( + sources + .map(({ source }) => parse(source)) + .map(({ feature, pickles }) => [feature, pickles]) + ); return createCucumber( sources, features, + picklesCol, globalStepDefinitionsToRequire, nonGlobalStepDefinitionsToRequire, getCucumberJsonConfig() diff --git a/lib/loader.js b/lib/loader.js index 6a315c6..9317248 100644 --- a/lib/loader.js +++ b/lib/loader.js @@ -8,7 +8,14 @@ const { getCucumberJsonConfig } = require("./getCucumberJsonConfig"); // This is the template for the file that we will send back to cypress instead of the text of a // feature file -const createCucumber = (filePath, cucumberJson, source, feature, toRequire) => +const createCucumber = ( + filePath, + cucumberJson, + source, + feature, + pickles, + toRequire +) => ` ${cucumberTemplate} @@ -17,7 +24,7 @@ const createCucumber = (filePath, cucumberJson, source, feature, toRequire) => ${toRequire.join("\n")} createTestsFromFeature('${filePath}', \`${jsStringEscape( source - )}\`, ${JSON.stringify(feature)}); + )}\`, ${JSON.stringify(feature)}, ${JSON.stringify(pickles)}); }); `; @@ -28,13 +35,14 @@ module.exports = function(source, filePath = this.resourcePath) { sdPath => `require('${sdPath}')` ); - const { feature } = parse(source.toString()); + const { feature, pickles } = parse(source.toString()); return createCucumber( path.basename(filePath), getCucumberJsonConfig(), source, feature, + pickles, stepDefinitionsToRequire ); }; diff --git a/lib/parserHelpers.js b/lib/parserHelpers.js index 1780b8e..f9c91fc 100644 --- a/lib/parserHelpers.js +++ b/lib/parserHelpers.js @@ -1,7 +1,17 @@ -const { generateEvents, Parser } = require("gherkin"); +const { generateEvents } = require("gherkin"); function parse(source) { - return new Parser().parse(source); + const events = generateEvents(source); + + const { + document: { feature } + } = events.find(event => event.type === "gherkin-document"); + + const pickles = events + .filter(event => event.type === "pickle") + .map(({ pickle }) => pickle); + + return { feature, pickles }; } module.exports = { generateEvents, parse }; diff --git a/lib/resolveStepDefinition.js b/lib/resolveStepDefinition.js index d319b43..e3362eb 100644 --- a/lib/resolveStepDefinition.js +++ b/lib/resolveStepDefinition.js @@ -39,7 +39,7 @@ class StepDefinitionRegistry { }); }; - this.resolve = (type, text, runningFeatureName) => { + this.resolve = (text, runningFeatureName) => { const matchingSteps = this.definitions.filter( ({ expression, featureName }) => expression.match(text) && @@ -88,65 +88,19 @@ const beforeHookRegistry = new HookRegistry(); const afterHookRegistry = new HookRegistry(); function resolveStepDefinition(step, featureName) { - const stepDefinition = stepDefinitionRegistry.resolve( - step.keyword.toLowerCase().trim(), - step.text, - featureName - ); + const stepDefinition = stepDefinitionRegistry.resolve(step.text, featureName); return stepDefinition || {}; } -function storeTemplateRowsOnArgumentIfNotPresent(argument) { - return !argument.templateRows - ? Object.assign({}, argument, { - templateRows: argument.rows - }) - : argument; -} - -function applyExampleData(argument, exampleRowData, replaceParameterTags) { - const argumentWithTemplateRows = storeTemplateRowsOnArgumentIfNotPresent( - argument - ); - - const scenarioDataTableRows = argumentWithTemplateRows.templateRows.map( - tr => { - if (!(tr && tr.type === "TableRow")) { - return tr; - } - const cells = { - cells: tr.cells.map(c => { - const value = { - value: replaceParameterTags(exampleRowData, c.value) - }; - return Object.assign({}, c, value); - }) - }; - return Object.assign({}, tr, cells); - } - ); - return Object.assign({}, argumentWithTemplateRows, { - rows: scenarioDataTableRows - }); -} - -function resolveStepArgument(argument, exampleRowData, replaceParameterTags) { +function resolveStepArgument(args) { + const [argument] = args; if (!argument) { return argument; } - if (argument.type === "DataTable") { - if (!exampleRowData) { - return new DataTable(argument); - } - const argumentWithAppliedExampleData = applyExampleData( - argument, - exampleRowData, - replaceParameterTags - ); - - return new DataTable(argumentWithAppliedExampleData); + if (argument.rows) { + return new DataTable(argument); } - if (argument.type === "DocString") { + if (argument.content) { return argument.content; } return argument; @@ -204,22 +158,13 @@ module.exports = { ); }, // eslint-disable-next-line func-names - resolveAndRunStepDefinition( - step, - replaceParameterTags, - exampleRowData, - featureName - ) { + resolveAndRunStepDefinition(step, featureName) { const { expression, implementation } = resolveStepDefinition( step, featureName ); const stepText = step.text; - const argument = resolveStepArgument( - step.argument, - exampleRowData, - replaceParameterTags - ); + const argument = resolveStepArgument(step.arguments); return implementation.call( this, ...expression.match(stepText).map(match => match.getValue()),