From c3d6ce098c7419305b4b22d0135bb6d8e3777ea1 Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Mon, 8 Aug 2022 14:18:54 +0200 Subject: [PATCH 1/6] Search for scenarios in rules --- analyzer.js | 83 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/analyzer.js b/analyzer.js index 7aa79d2..431600c 100644 --- a/analyzer.js +++ b/analyzer.js @@ -7,7 +7,20 @@ const path = require('path'); let workDir; const invalidKeys = ['And', 'But']; -const getLocation = scenario => (scenario.tags.length ? scenario.tags[0].location.line - 1 : scenario.location.line - 1); +const getLocation = ({ feature, scenario, rule }) => { + const message = feature || scenario || rule; + if (message.tags && message.tags.length) { + return message.tags[0].location.line - 1; + } + return message.location.line - 1; +}; + +const getLocations = ({ feature, scenario, rule }) => { + if (feature) return feature.children.flatMap(getLocations); + if (scenario) return [ getLocation({ scenario}) ]; + if (rule) return [getLocation({ rule }), ...rule.children.flatMap(getLocations)]; + return []; +}; const getTitle = scenario => { let { name } = scenario; @@ -28,37 +41,51 @@ const getScenarioCode = (source, feature, file) => { const sourceArray = source.split('\n'); const fileName = path.relative(workDir, file); const scenarios = []; - - for (let i = 0; i < feature.children.length; i += 1) { - const { scenario } = feature.children[i]; - if (scenario) { - if (!scenario.name) { - console.log(chalk.red('Title of scenario cannot be empty, skipping this')); + const [_, ...endLocations] = [...getLocations({ feature }), sourceArray.length]; + let inRule = false; + + const handleScenario = (scenario) => { + if (!scenario.name) { + console.log(chalk.red('Title of scenario cannot be empty, skipping this')); + } else { + console.log(inRule ? ' - ' : ' - ', scenario.name); + } + const steps = []; + let previousValidStep = ''; + const scenarioJson = { name: scenario.name, file: fileName }; + const start = getLocation({ scenario }); + const end = endLocations.shift(); + for (const step of scenario.steps) { + let keyword = step.keyword.trim(); + if (invalidKeys.includes(keyword)) { + keyword = previousValidStep; } else { - console.log(' - ', scenario.name); + previousValidStep = keyword; } - const steps = []; - let previousValidStep = ''; - const scenarioJson = { name: scenario.name, file: fileName }; - const start = getLocation(scenario); - const end = ((i === feature.children.length - 1) ? sourceArray.length : getLocation(feature.children[i + 1].scenario)); - for (const step of scenario.steps) { - let keyword = step.keyword.trim(); - if (invalidKeys.includes(keyword)) { - keyword = previousValidStep; - } else { - previousValidStep = keyword; - } - steps.push({ title: step.text, keyword }); - } - scenarioJson.line = start; - scenarioJson.tags = scenario.tags.map(t => t.name.slice(1)); - scenarioJson.code = sourceArray.slice(start, end).join('\n'); - scenarioJson.steps = steps; - scenarios.push(scenarioJson); + steps.push({ title: step.text, keyword }); } + scenarioJson.line = start; + scenarioJson.tags = scenario.tags.map(t => t.name.slice(1)); + scenarioJson.code = sourceArray.slice(start, end).join('\n'); + scenarioJson.steps = steps; + scenarios.push(scenarioJson); + }; + + const handleRule = (rule) => { + console.log(inRule ? ' - ' : ' - ', rule.name); + endLocations.shift(); + inRule = true; + rule.children.forEach(handleChild); + inRule = false; + }; + + const handleChild = ({ scenario, rule }) => { + if (scenario) handleScenario(scenario); + if (rule) handleRule(rule); } + feature.children.forEach(handleChild); + return scenarios; }; @@ -89,7 +116,7 @@ const parseFile = file => new Promise((resolve, reject) => { console.log(chalk.red('Title for feature is empty, skipping')); featureData.error = `${fileName} : Empty feature`; } - featureData.line = getLocation(data[1].gherkinDocument.feature) + 1; + featureData.line = getLocation({feature: data[1].gherkinDocument.feature}) + 1; featureData.tags = data[1].gherkinDocument.feature.tags.map(t => t.name.slice(1)); featureData.scenario = getScenarioCode(data[0].source.data, data[1].gherkinDocument.feature, file); } else { From 905b9852f012a9756b6c2f3b42fc3db884d716cc Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Mon, 8 Aug 2022 15:38:07 +0200 Subject: [PATCH 2/6] Add test for discovery of scenarios in rules --- example/features/rules.feature | 74 ++++++++++++++++++++++++++++++++++ tests/analyzer_test.js | 21 ++++++++++ 2 files changed, 95 insertions(+) create mode 100644 example/features/rules.feature diff --git a/example/features/rules.feature b/example/features/rules.feature new file mode 100644 index 0000000..5f5ccf5 --- /dev/null +++ b/example/features/rules.feature @@ -0,0 +1,74 @@ +Feature: A feature with multiple rules + Description of the feature + + Rule: Rule 1 + Description of first rule + + Scenario: Scenario 1.1 + Description of first scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 1.2 + Description of second scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 1.3 + Description of third scenario + + Given I have something + When I do something + Then something happens + + Rule: Rule 2 + Description of second rule + + Scenario: Scenario 2.1 + Description of first scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 2.2 + Description of second scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 2.3 + Description of third scenario + + Given I have something + When I do something + Then something happens + + Rule: Rule 3 + Description of thrid rule + + Scenario: Scenario 3.1 + Description of first scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 3.2 + Description of second scenario + + Given I have something + When I do something + Then something happens + + Scenario: Scenario 3.3 + Description of third scenario + + Given I have something + When I do something + Then something happens diff --git a/tests/analyzer_test.js b/tests/analyzer_test.js index 19a1683..c2b2ef0 100644 --- a/tests/analyzer_test.js +++ b/tests/analyzer_test.js @@ -51,4 +51,25 @@ describe('Analyzer', () => { const features = await analyse('**/empty.feature', path.join(__dirname, '..', 'example')); expect(features[0].error).not.equal(undefined); }); + + it('Should include scenarios from rules', async () => { + const features = await analyse('**/rules.feature', path.join(__dirname, '..', 'example')); + const scenarios = features.reduce((acc, feature) => { + acc.push(...feature.scenario); + return acc; + }, []); + const scenariosTitles = scenarios.map(scenarioData => scenarioData.name); + + expect(features.length).equal(1); + expect(scenariosTitles).to.include('Scenario 1.1'); + expect(scenariosTitles).to.include('Scenario 1.2'); + expect(scenariosTitles).to.include('Scenario 1.3'); + expect(scenariosTitles).to.include('Scenario 2.1'); + expect(scenariosTitles).to.include('Scenario 2.2'); + expect(scenariosTitles).to.include('Scenario 2.3'); + expect(scenariosTitles).to.include('Scenario 3.1'); + expect(scenariosTitles).to.include('Scenario 3.2'); + expect(scenariosTitles).to.include('Scenario 3.3'); + expect(scenarios.length).equal(9); + }); }); From e5dee001c9907091bba9733c0b00632d98eb3c6f Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Mon, 8 Aug 2022 15:53:33 +0200 Subject: [PATCH 3/6] Add command line options to include contextual information Currently the code in Testomatio only shows the scenario's source code. However, the descriptions of parent features and rules as well as the steps of background sections may also be relevant to the tester. Thus, this PR adds command line options to include the source code of these elemnts in the exported scenatios. --- analyzer.js | 60 ++++++++++++++++++++++++++++++++++++++++------------ bin/check.js | 11 ++++++++-- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/analyzer.js b/analyzer.js index 431600c..7d0827e 100644 --- a/analyzer.js +++ b/analyzer.js @@ -7,18 +7,19 @@ const path = require('path'); let workDir; const invalidKeys = ['And', 'But']; -const getLocation = ({ feature, scenario, rule }) => { - const message = feature || scenario || rule; +const getLocation = ({ feature, scenario, rule, background }) => { + const message = feature || scenario || rule || background; if (message.tags && message.tags.length) { return message.tags[0].location.line - 1; } return message.location.line - 1; }; -const getLocations = ({ feature, scenario, rule }) => { +const getLocations = ({ feature, scenario, rule, background }) => { if (feature) return feature.children.flatMap(getLocations); - if (scenario) return [ getLocation({ scenario}) ]; + if (scenario) return [ getLocation({ scenario }) ]; if (rule) return [getLocation({ rule }), ...rule.children.flatMap(getLocations)]; + if (background) return [ getLocation({ background }) ]; return []; }; @@ -37,11 +38,20 @@ const getTitle = scenario => { return name; }; -const getScenarioCode = (source, feature, file) => { +const getScenarioCode = (source, feature, file, { + includeFeatureCode, + includeRuleCode, + includeBackgroundCode, +}) => { const sourceArray = source.split('\n'); const fileName = path.relative(workDir, file); const scenarios = []; - const [_, ...endLocations] = [...getLocations({ feature }), sourceArray.length]; + + const featureStart = getLocation({ feature }); + const startLocations = getLocations({ feature }); + const [featureEnd, ...endLocations] = [...startLocations, sourceArray.length]; + const context = includeFeatureCode ? sourceArray.slice(featureStart, featureEnd) : []; + let inRule = false; const handleScenario = (scenario) => { @@ -53,7 +63,7 @@ const getScenarioCode = (source, feature, file) => { const steps = []; let previousValidStep = ''; const scenarioJson = { name: scenario.name, file: fileName }; - const start = getLocation({ scenario }); + const start = startLocations.shift(); const end = endLocations.shift(); for (const step of scenario.steps) { let keyword = step.keyword.trim(); @@ -66,22 +76,40 @@ const getScenarioCode = (source, feature, file) => { } scenarioJson.line = start; scenarioJson.tags = scenario.tags.map(t => t.name.slice(1)); - scenarioJson.code = sourceArray.slice(start, end).join('\n'); + scenarioJson.code = context.concat(sourceArray.slice(start, end)).join('\n'); scenarioJson.steps = steps; scenarios.push(scenarioJson); }; const handleRule = (rule) => { console.log(inRule ? ' - ' : ' - ', rule.name); - endLocations.shift(); + const oldContextLength = context.length; + const start = startLocations.shift(); + const end = endLocations.shift(); + + if (includeRuleCode) { + context.push(...sourceArray.slice(start, end)); + } + inRule = true; rule.children.forEach(handleChild); inRule = false; + + context.splice(oldContextLength); + }; + + const handleBackground = (background) => { + const start = startLocations.shift(); + const end = endLocations.shift(); + if (includeBackgroundCode) { + context.push(...sourceArray.slice(start, end)); + } }; - const handleChild = ({ scenario, rule }) => { + const handleChild = ({ scenario, rule, background }) => { if (scenario) handleScenario(scenario); if (rule) handleRule(rule); + if (background) handleBackground(background); } feature.children.forEach(handleChild); @@ -89,7 +117,7 @@ const getScenarioCode = (source, feature, file) => { return scenarios; }; -const parseFile = file => new Promise((resolve, reject) => { +const parseFile = (file, scenarioCodeOptions) => new Promise((resolve, reject) => { try { const options = { includeSource: true, @@ -118,7 +146,7 @@ const parseFile = file => new Promise((resolve, reject) => { } featureData.line = getLocation({feature: data[1].gherkinDocument.feature}) + 1; featureData.tags = data[1].gherkinDocument.feature.tags.map(t => t.name.slice(1)); - featureData.scenario = getScenarioCode(data[0].source.data, data[1].gherkinDocument.feature, file); + featureData.scenario = getScenarioCode(data[0].source.data, data[1].gherkinDocument.feature, file, scenarioCodeOptions); } else { featureData.error = `${fileName} : ${data[1].attachment.data}`; console.log(chalk.red(`Wrong format, So skipping this: ${data[1].attachment.data}`)); @@ -136,8 +164,12 @@ const parseFile = file => new Promise((resolve, reject) => { * * @param {String} filePattern * @param {String} dir + * @param {Object} scenarioCodeOptions + * @param {boolean} scenarioCodeOptions.includeFeatureCode + * @param {boolean} scenarioCodeOptions.includeRuleCode + * @param {boolean} scenarioCodeOptions.includeBackgroundCode */ -const analyzeFeatureFiles = (filePattern, dir = '.') => { +const analyzeFeatureFiles = (filePattern, dir = '.', scenarioCodeOptions = {}) => { workDir = dir; console.log('\n 🗄️ Parsing files\n'); @@ -147,7 +179,7 @@ const analyzeFeatureFiles = (filePattern, dir = '.') => { const promiseArray = []; glob(pattern, (er, files) => { for (const file of files) { - const data = parseFile(file); + const data = parseFile(file, scenarioCodeOptions); promiseArray.push(data); } diff --git a/bin/check.js b/bin/check.js index 0d49af5..c4b5ed4 100755 --- a/bin/check.js +++ b/bin/check.js @@ -32,10 +32,17 @@ program .option('--create', 'Create tests and suites for missing IDs') .option('--no-empty', 'Remove empty suites after import') .option('--keep-structure', 'Prefer structure of source code over structure in Testomat.io') - .option('--no-detached', 'Don\t mark all unmatched tests as detached') + .option('--no-detached', 'Don\'t mark all unmatched tests as detached') + .option('--include-features', 'Add description of feature to scenario code') + .option('--include-rules', 'Add description of parent rule sections to scenario code') + .option('--include-backgrounds', 'Add description and steps of relevant background sections to scenario code') .action(async (filesArg, opts) => { const isPattern = checkPattern(filesArg); - const features = await analyze(filesArg || '**/*.feature', opts.dir || process.cwd()); + const features = await analyze(filesArg || '**/*.feature', opts.dir || process.cwd(), { + includeFeatureCode: opts.includeFeatures, + includeRuleCode: opts.includeRules, + includeBackgroundCode: opts.includeBackgrounds, + }); if (opts.cleanIds || opts.unsafeCleanIds) { let idMap = {}; if (apiKey) { From bf01d293b89164142039fafef18deac2caddc134 Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Mon, 8 Aug 2022 16:13:48 +0200 Subject: [PATCH 4/6] Add tests for the inclusion of feature, rule and background code --- example/features/rules.feature | 14 ++++- tests/analyzer_test.js | 110 +++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/example/features/rules.feature b/example/features/rules.feature index 5f5ccf5..ec0bf74 100644 --- a/example/features/rules.feature +++ b/example/features/rules.feature @@ -1,9 +1,15 @@ Feature: A feature with multiple rules Description of the feature + Background: + Given all rules have something + Rule: Rule 1 Description of first rule + Background: + Given the first rule has something + Scenario: Scenario 1.1 Description of first scenario @@ -28,6 +34,9 @@ Feature: A feature with multiple rules Rule: Rule 2 Description of second rule + Background: + Given the second rule has something + Scenario: Scenario 2.1 Description of first scenario @@ -50,7 +59,10 @@ Feature: A feature with multiple rules Then something happens Rule: Rule 3 - Description of thrid rule + Description of third rule + + Background: + Given the third rule has something Scenario: Scenario 3.1 Description of first scenario diff --git a/tests/analyzer_test.js b/tests/analyzer_test.js index c2b2ef0..5f971b3 100644 --- a/tests/analyzer_test.js +++ b/tests/analyzer_test.js @@ -72,4 +72,114 @@ describe('Analyzer', () => { expect(scenariosTitles).to.include('Scenario 3.3'); expect(scenarios.length).equal(9); }); + + it('Does not include the feature, rule and background description by default', async () => { + const features = await analyse('**/rules.feature', path.join(__dirname, '..', 'example'), { + includeFeatureCode: false, + includeRuleCode: false, + includeBackgroundCode: false, + }); + const scenarios = features.reduce((acc, feature) => { + acc.push(...feature.scenario); + return acc; + }, []); + + expect(features.length).equal(1); + for (let scenario of scenarios) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.not.include('Description of the feature'); + expect(code).to.not.include('Description of first rule'); + expect(code).to.not.include('Given all rules have something'); + } + expect(scenarios.length).equal(9); + }); + + it('Can include the features description', async () => { + const features = await analyse('**/rules.feature', path.join(__dirname, '..', 'example'), { + includeFeatureCode: true, + includeRuleCode: false, + includeBackgroundCode: false, + }); + const scenarios = features.reduce((acc, feature) => { + acc.push(...feature.scenario); + return acc; + }, []); + + expect(features.length).equal(1); + for (let scenario of scenarios) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.include('Description of the feature'); + } + expect(scenarios.length).equal(9); + }); + + it('Can include the parent rule description', async () => { + const features = await analyse('**/rules.feature', path.join(__dirname, '..', 'example'), { + includeFeatureCode: false, + includeRuleCode: true, + includeBackgroundCode: false, + }); + const scenarios = features.reduce((acc, feature) => { + acc.push(...feature.scenario); + return acc; + }, []); + + expect(features.length).equal(1); + for (let scenario of scenarios.slice(0, 3)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.include('Description of first rule'); + expect(code).to.not.include('Description of second rule'); + expect(code).to.not.include('Description of third rule'); + } + for (let scenario of scenarios.slice(3, 6)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.not.include('Description of first rule'); + expect(code).to.include('Description of second rule'); + expect(code).to.not.include('Description of third rule'); + } + for (let scenario of scenarios.slice(6, 9)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.not.include('Description of first rule'); + expect(code).to.not.include('Description of second rule'); + expect(code).to.include('Description of third rule'); + } + expect(scenarios.length).equal(9); + }); + + it('Can include background steps', async () => { + const features = await analyse('**/rules.feature', path.join(__dirname, '..', 'example'), { + includeFeatureCode: false, + includeRuleCode: false, + includeBackgroundCode: true, + }); + const scenarios = features.reduce((acc, feature) => { + acc.push(...feature.scenario); + return acc; + }, []); + + expect(features.length).equal(1); + for (let scenario of scenarios) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.include('Given all rules have something'); + } + for (let scenario of scenarios.slice(0, 3)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.include('Given the first rule has something'); + expect(code).to.not.include('Given the second rule has something'); + expect(code).to.not.include('Given the third rule has something'); + } + for (let scenario of scenarios.slice(3, 6)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.not.include('Given the first rule has something'); + expect(code).to.include('Given the second rule has something'); + expect(code).to.not.include('Given the third rule has something'); + } + for (let scenario of scenarios.slice(6, 9)) { + const code = scenario.code.split('\n').map(line => line.trim()); + expect(code).to.not.include('Given the first rule has something'); + expect(code).to.not.include('Given the second rule has something'); + expect(code).to.include('Given the third rule has something'); + } + expect(scenarios.length).equal(9); + }); }); From f57ec57df469391407f737e7eb6f8d40c4a8890d Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Tue, 9 Aug 2022 12:46:53 +0200 Subject: [PATCH 5/6] Remove destructuring from `getLocation` In `getLocations` the child message is destructured already. Since there is no invocation of `getLocation` on a child node, the second destructuring is not needed. --- analyzer.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/analyzer.js b/analyzer.js index 7d0827e..0ff88af 100644 --- a/analyzer.js +++ b/analyzer.js @@ -7,8 +7,7 @@ const path = require('path'); let workDir; const invalidKeys = ['And', 'But']; -const getLocation = ({ feature, scenario, rule, background }) => { - const message = feature || scenario || rule || background; +const getLocation = (message) => { if (message.tags && message.tags.length) { return message.tags[0].location.line - 1; } @@ -16,10 +15,10 @@ const getLocation = ({ feature, scenario, rule, background }) => { }; const getLocations = ({ feature, scenario, rule, background }) => { - if (feature) return feature.children.flatMap(getLocations); - if (scenario) return [ getLocation({ scenario }) ]; - if (rule) return [getLocation({ rule }), ...rule.children.flatMap(getLocations)]; - if (background) return [ getLocation({ background }) ]; + if (feature) return [getLocation(feature), ...feature.children.flatMap(getLocations) ]; + if (scenario) return [ getLocation(scenario) ]; + if (rule) return [getLocation(rule), ...rule.children.flatMap(getLocations)]; + if (background) return [ getLocation(background) ]; return []; }; @@ -47,8 +46,7 @@ const getScenarioCode = (source, feature, file, { const fileName = path.relative(workDir, file); const scenarios = []; - const featureStart = getLocation({ feature }); - const startLocations = getLocations({ feature }); + const [featureStart, ...startLocations] = getLocations({ feature }); const [featureEnd, ...endLocations] = [...startLocations, sourceArray.length]; const context = includeFeatureCode ? sourceArray.slice(featureStart, featureEnd) : []; @@ -144,7 +142,7 @@ const parseFile = (file, scenarioCodeOptions) => new Promise((resolve, reject) = console.log(chalk.red('Title for feature is empty, skipping')); featureData.error = `${fileName} : Empty feature`; } - featureData.line = getLocation({feature: data[1].gherkinDocument.feature}) + 1; + featureData.line = getLocation(data[1].gherkinDocument.feature) + 1; featureData.tags = data[1].gherkinDocument.feature.tags.map(t => t.name.slice(1)); featureData.scenario = getScenarioCode(data[0].source.data, data[1].gherkinDocument.feature, file, scenarioCodeOptions); } else { From 38bd9168865a76effd353c3ddcb182c45cc644b1 Mon Sep 17 00:00:00 2001 From: Justin Andresen Date: Tue, 9 Aug 2022 12:53:00 +0200 Subject: [PATCH 6/6] Never indent rules A `Rule` cannot be the child of another rule. Thus, the check if we are `inRule` is not needed. If nested rules were allowed in the future, more than a single space of indentation would be needed anyway. --- analyzer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analyzer.js b/analyzer.js index 431600c..1cc163c 100644 --- a/analyzer.js +++ b/analyzer.js @@ -72,7 +72,7 @@ const getScenarioCode = (source, feature, file) => { }; const handleRule = (rule) => { - console.log(inRule ? ' - ' : ' - ', rule.name); + console.log(' - ', rule.name); endLocations.shift(); inRule = true; rule.children.forEach(handleChild);