diff --git a/.changeset/silly-suits-attend.md b/.changeset/silly-suits-attend.md new file mode 100644 index 0000000..3a867e2 --- /dev/null +++ b/.changeset/silly-suits-attend.md @@ -0,0 +1,8 @@ +--- +"quickpickle": patch +--- + +Fixed several errors in rendering, including: + +- \`Backticks` and ${variables} were not escaped properly in Scenario Outlines +- Backslashes were not escaped properly in other strings diff --git a/packages/main/gherkin-example/example.feature b/packages/main/gherkin-example/example.feature index 2766ab1..5be1ee3 100644 --- a/packages/main/gherkin-example/example.feature +++ b/packages/main/gherkin-example/example.feature @@ -124,3 +124,31 @@ Feature: QuickPickle's Comprehensive Gherkin Syntax Example Given an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]] When this Scenario is run Then it should be split into 4 tests + + Scenario Outline: Search Ordering: () + When I search for "" and get results from 500 books + Then I should see search results with these metrics: + | Book Importance | Match Quality | score | + | primary | exact | 3+5=8 | + And next I should see search results with these metrics: + | secondary | exact | 2+5=7 | + Examples: + | searchPhrase | religion | context | + | ablution | Bahá’í | teachings on prayer | + | achieving enlightenment | Buddhist | purpose of existence | + | achieving immortality | Taoist | purpose of existence | + | acts of kindness | Jewish | teachings on charity | + + Scenario Outline: `someone` is ${sneaky} \${with} \\${backslashes} \\\${and} \$\{other} \`things\\` 'like' \'quotes\\' + Given a "string with \"quotes\"" + And `someone` is ${sneaky} \${with} \\${backslashes} \\\${and} \$\{other} \`things\\` 'like' \'quotes\\' + Examples: + | 1 | 2 | 3 | + | \'a\' | \`b\` | \"c\" | + | ${a} | \${b} | \\${c} | + | `a` | \`b\` | \\`c\\` | + + Scenario: `someone` is ${sneaky} \${with} \\${backslashes} \\\${and} \$\{other} \`things\\` 'like' \'quotes\\' + Given a "string with \"quotes\"" + And `someone` is ${sneaky} \${with} \\${backslashes} \\\${and} \$\{other} \`things\\` 'like' \'quotes\\' + diff --git a/packages/main/gherkin-example/example.feature.js b/packages/main/gherkin-example/example.feature.js index e9034ff..d84e68b 100644 --- a/packages/main/gherkin-example/example.feature.js +++ b/packages/main/gherkin-example/example.feature.js @@ -33,8 +33,8 @@ const initScenario = async(context, scenario, tags, steps) => { state.info.scenario = scenario; state.info.tags = [...tags]; await applyBeforeHooks(state); - await gherkinStep('a common precondition', state, 10, -1); - await gherkinStep('another common precondition', state, 11, -2); + await gherkinStep(`a common precondition`, state, 10, -1); + await gherkinStep(`another common precondition`, state, 11, -2); return state; } @@ -42,9 +42,9 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: Basic scenario example\' (@tag @multiple_tags @scenario_tag)', async (context) => { let state = await initScenario(context, 'Basic scenario example\'', ['@tag', '@multiple_tags', '@scenario_tag'], [`an initial context'`,`an action is performed'`,`a verifiable outcome is achieved'`]); - await gherkinStep('an initial context\'', state, 15, 1); - await gherkinStep('an action is performed\'', state, 16, 2); - await gherkinStep('a verifiable outcome is achieved\'', state, 17, 3); + await gherkinStep(`an initial context'`, state, 15, 1); + await gherkinStep(`an action is performed'`, state, 16, 2); + await gherkinStep(`a verifiable outcome is achieved'`, state, 17, 3); await afterScenario(state); }); @@ -65,13 +65,13 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: Scenario with various DataTable types (@tag @multiple_tags @data_table)', async (context) => { let state = await initScenario(context, 'Scenario with various DataTable types', ['@tag', '@multiple_tags', '@data_table'], [`a list of strings:`,`a list of integers:`,`a map of string to string:`,`a list of maps:`,`a map of string to list of string:`,`they are processed`,`the system behaves correctly`]); - await gherkinStep('a list of strings:', state, 33, 1, undefined, [["Apple'"],["Banana`"],["Cherry\""]]); - await gherkinStep('a list of integers:', state, 37, 2, undefined, [["1"],["2"],["3"]]); - await gherkinStep('a map of string to string:', state, 41, 3, undefined, [["key1'","value1'"],["key2`","value2\""]]); - await gherkinStep('a list of maps:', state, 44, 4, undefined, [["name'","age`","role\""],["Alice'","30","admin\""],["Bob`","25","user\""]]); - await gherkinStep('a map of string to list of string:', state, 48, 5, undefined, [["fruits","Apple, Banana, Cherry"],["vegetables","Carrot, Potato, Onion"]]); - await gherkinStep('they are processed', state, 51, 6); - await gherkinStep('the system behaves correctly', state, 52, 7); + await gherkinStep(`a list of strings:`, state, 33, 1, undefined, [["Apple'"],["Banana`"],["Cherry\""]]); + await gherkinStep(`a list of integers:`, state, 37, 2, undefined, [["1"],["2"],["3"]]); + await gherkinStep(`a map of string to string:`, state, 41, 3, undefined, [["key1'","value1'"],["key2`","value2\""]]); + await gherkinStep(`a list of maps:`, state, 44, 4, undefined, [["name'","age`","role\""],["Alice'","30","admin\""],["Bob`","25","user\""]]); + await gherkinStep(`a map of string to list of string:`, state, 48, 5, undefined, [["fruits","Apple, Banana, Cherry"],["vegetables","Carrot, Potato, Onion"]]); + await gherkinStep(`they are processed`, state, 51, 6); + await gherkinStep(`the system behaves correctly`, state, 52, 7); await afterScenario(state); }); @@ -80,58 +80,58 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { const initRuleScenario = async (context, scenario, tags, steps) => { let state = await initScenario(context, scenario, tags, steps); state.info.rule = 'Business rule description\''; - await gherkinStep('a specific rule context', state, 59, -1); - await gherkinStep('another specific rule context', state, 60, -2); + await gherkinStep(`a specific rule context`, state, 59, -1); + await gherkinStep(`another specific rule context`, state, 60, -2); return state; } test('Example: Rule example scenario\' (@tag @multiple_tags @rule_tag)', async (context) => { let state = await initRuleScenario(context, 'Rule example scenario\'', ['@tag', '@multiple_tags', '@rule_tag'], [`a specific rule context`,`a rule-related action occurs`,`the rule outcome is observed`]); - await gherkinStep('a specific rule context', state, 63, 1); - await gherkinStep('a rule-related action occurs', state, 64, 2); - await gherkinStep('the rule outcome is observed', state, 65, 3); + await gherkinStep(`a specific rule context`, state, 63, 1); + await gherkinStep(`a rule-related action occurs`, state, 64, 2); + await gherkinStep(`the rule outcome is observed`, state, 65, 3); await afterScenario(state); }); test('Scenario: Also a rule example\' (@tag @multiple_tags @rule_tag)', async (context) => { let state = await initRuleScenario(context, 'Also a rule example\'', ['@tag', '@multiple_tags', '@rule_tag'], [`a Rule statement`,`a scenario is below it`,`it is a child of the Rule, even if it isn't indented`]); - await gherkinStep('a Rule statement', state, 68, 1); - await gherkinStep('a scenario is below it', state, 69, 2); - await gherkinStep('it is a child of the Rule, even if it isn\'t indented', state, 70, 3); + await gherkinStep(`a Rule statement`, state, 68, 1); + await gherkinStep(`a scenario is below it`, state, 69, 2); + await gherkinStep(`it is a child of the Rule, even if it isn't indented`, state, 70, 3); await afterScenario(state); }); test.todo.skip('Scenario: Scenario with doc string (@tag @multiple_tags @rule_tag @wip @skip)', async (context) => { let state = await initRuleScenario(context, 'Scenario with doc string', ['@tag', '@multiple_tags', '@rule_tag', '@wip', '@skip'], [`a document with the following content:`,`the document is processed`,`the system handles it correctly`]); - await gherkinStep('a document with the following content:', state, 76, 1, undefined, {"content":"This is a doc string.\nIt can contain multiple lines.\nUseful for specifying larger text inputs."}); - await gherkinStep('the document is processed', state, 82, 2); - await gherkinStep('the system handles it correctly', state, 83, 3); + await gherkinStep(`a document with the following content:`, state, 76, 1, undefined, {"content":"This is a doc string.\nIt can contain multiple lines.\nUseful for specifying larger text inputs."}); + await gherkinStep(`the document is processed`, state, 82, 2); + await gherkinStep(`the system handles it correctly`, state, 83, 3); await afterScenario(state); }); test('Scenario: Scenario with content type doc string (@tag @multiple_tags @rule_tag)', async (context) => { let state = await initRuleScenario(context, 'Scenario with content type doc string', ['@tag', '@multiple_tags', '@rule_tag'], [`a document with the following Markdown content:`]); - await gherkinStep('a document with the following Markdown content:', state, 86, 1, undefined, {"content":"Lorem Ipsum\n===============\nLorem ipsum dolor sit amet,\nconsectetur adipiscing elit.","mediaType":"markdown"}); + await gherkinStep(`a document with the following Markdown content:`, state, 86, 1, undefined, {"content":"Lorem Ipsum\n===============\nLorem ipsum dolor sit amet,\nconsectetur adipiscing elit.","mediaType":"markdown"}); await afterScenario(state); }); test.sequential('Scenario: Scenario with And and But steps (@tag @multiple_tags @rule_tag @sequential)', async (context) => { let state = await initRuleScenario(context, 'Scenario with And and But steps', ['@tag', '@multiple_tags', '@rule_tag', '@sequential'], [`an initial state`,`some additional context`,`an action is performed`,`another action is performed`,`some assertion is made`,`some exception is also handled`]); - await gherkinStep('an initial state', state, 95, 1); - await gherkinStep('some additional context', state, 96, 2); - await gherkinStep('an action is performed', state, 97, 3); - await gherkinStep('another action is performed', state, 98, 4); - await gherkinStep('some assertion is made', state, 99, 5); - await gherkinStep('some exception is also handled', state, 100, 6); + await gherkinStep(`an initial state`, state, 95, 1); + await gherkinStep(`some additional context`, state, 96, 2); + await gherkinStep(`an action is performed`, state, 97, 3); + await gherkinStep(`another action is performed`, state, 98, 4); + await gherkinStep(`some assertion is made`, state, 99, 5); + await gherkinStep(`some exception is also handled`, state, 100, 6); await afterScenario(state); }); test.fails('Scenario: Failing scenario example (@tag @multiple_tags @rule_tag @fails)', async (context) => { let state = await initRuleScenario(context, 'Failing scenario example', ['@tag', '@multiple_tags', '@rule_tag', '@fails'], [`a condition that will fail`,`an impossible action is attempted`,`an unreachable assertion is made`]); - await gherkinStep('a condition that will fail', state, 104, 1); - await gherkinStep('an impossible action is attempted', state, 105, 2); - await gherkinStep('an unreachable assertion is made', state, 106, 3); + await gherkinStep(`a condition that will fail`, state, 104, 1); + await gherkinStep(`an impossible action is attempted`, state, 105, 2); + await gherkinStep(`an unreachable assertion is made`, state, 106, 3); await afterScenario(state); }); @@ -150,17 +150,17 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Example: This rule doesn\'t nest (@tag @multiple_tags)', async (context) => { let state = await initRuleScenario(context, 'This rule doesn\'t nest', ['@tag', '@multiple_tags'], [`a Rule statement`,`another Rule is indented below it`,`the indented Rule is NOT a child of the previous Rule`]); - await gherkinStep('a Rule statement', state, 112, 1); - await gherkinStep('another Rule is indented below it', state, 113, 2); - await gherkinStep('the indented Rule is NOT a child of the previous Rule', state, 114, 3); + await gherkinStep(`a Rule statement`, state, 112, 1); + await gherkinStep(`another Rule is indented below it`, state, 113, 2); + await gherkinStep(`the indented Rule is NOT a child of the previous Rule`, state, 114, 3); await afterScenario(state); }); test('Scenario: Exploded tags make multiple tests (@tag @multiple_tags @1a)', async (context) => { let state = await initRuleScenario(context, 'Exploded tags make multiple tests', ['@tag', '@multiple_tags', '@1a'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 2 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 118, 1, 1); - await gherkinStep('this Scenario is run', state, 119, 2, 1); - await gherkinStep('it should be split into 2 tests', state, 120, 3, 1); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 118, 1, 1); + await gherkinStep(`this Scenario is run`, state, 119, 2, 1); + await gherkinStep(`it should be split into 2 tests`, state, 120, 3, 1); await afterScenario(state); }); @@ -168,17 +168,17 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: Exploded tags make multiple tests (@tag @multiple_tags @1b)', async (context) => { let state = await initRuleScenario(context, 'Exploded tags make multiple tests', ['@tag', '@multiple_tags', '@1b'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 2 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 118, 1, 2); - await gherkinStep('this Scenario is run', state, 119, 2, 2); - await gherkinStep('it should be split into 2 tests', state, 120, 3, 2); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 118, 1, 2); + await gherkinStep(`this Scenario is run`, state, 119, 2, 2); + await gherkinStep(`it should be split into 2 tests`, state, 120, 3, 2); await afterScenario(state); }); test('Scenario: More tags make more tests (@tag @multiple_tags @tag3 @1a @2a)', async (context) => { let state = await initRuleScenario(context, 'More tags make more tests', ['@tag', '@multiple_tags', '@tag3', '@1a', '@2a'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 4 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 124, 1, 1); - await gherkinStep('this Scenario is run', state, 125, 2, 1); - await gherkinStep('it should be split into 4 tests', state, 126, 3, 1); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 124, 1, 1); + await gherkinStep(`this Scenario is run`, state, 125, 2, 1); + await gherkinStep(`it should be split into 4 tests`, state, 126, 3, 1); await afterScenario(state); }); @@ -186,9 +186,9 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: More tags make more tests (@tag @multiple_tags @tag3 @1a @2b)', async (context) => { let state = await initRuleScenario(context, 'More tags make more tests', ['@tag', '@multiple_tags', '@tag3', '@1a', '@2b'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 4 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 124, 1, 2); - await gherkinStep('this Scenario is run', state, 125, 2, 2); - await gherkinStep('it should be split into 4 tests', state, 126, 3, 2); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 124, 1, 2); + await gherkinStep(`this Scenario is run`, state, 125, 2, 2); + await gherkinStep(`it should be split into 4 tests`, state, 126, 3, 2); await afterScenario(state); }); @@ -196,9 +196,9 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: More tags make more tests (@tag @multiple_tags @tag3 @1b @2a)', async (context) => { let state = await initRuleScenario(context, 'More tags make more tests', ['@tag', '@multiple_tags', '@tag3', '@1b', '@2a'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 4 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 124, 1, 3); - await gherkinStep('this Scenario is run', state, 125, 2, 3); - await gherkinStep('it should be split into 4 tests', state, 126, 3, 3); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 124, 1, 3); + await gherkinStep(`this Scenario is run`, state, 125, 2, 3); + await gherkinStep(`it should be split into 4 tests`, state, 126, 3, 3); await afterScenario(state); }); @@ -206,9 +206,46 @@ describe('Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', () => { test('Scenario: More tags make more tests (@tag @multiple_tags @tag3 @1b @2b)', async (context) => { let state = await initRuleScenario(context, 'More tags make more tests', ['@tag', '@multiple_tags', '@tag3', '@1b', '@2b'], [`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`,`this Scenario is run`,`it should be split into 4 tests`]); - await gherkinStep('an explodedTags config of [[ \'@1a\',\'@1b\' ], [ \'@2a\',\'@2b\' ]]', state, 124, 1, 4); - await gherkinStep('this Scenario is run', state, 125, 2, 4); - await gherkinStep('it should be split into 4 tests', state, 126, 3, 4); + await gherkinStep(`an explodedTags config of [[ '@1a','@1b' ], [ '@2a','@2b' ]]`, state, 124, 1, 4); + await gherkinStep(`this Scenario is run`, state, 125, 2, 4); + await gherkinStep(`it should be split into 4 tests`, state, 126, 3, 4); + await afterScenario(state); + }); + + test.for([ + {"_0":"ablution","_1":"Bahá’í","_2":"teachings on prayer"}, + {"_0":"achieving enlightenment","_1":"Buddhist","_2":"purpose of existence"}, + {"_0":"achieving immortality","_1":"Taoist","_2":"purpose of existence"}, + {"_0":"acts of kindness","_1":"Jewish","_2":"teachings on charity"} + ])( + 'Scenario Outline: Search Ordering: $_0 ($_1) $_2 (@tag @multiple_tags)', + async ({ _0, _1, _2 }, context) => { + let state = await initRuleScenario(context, `Search Ordering: ${_0} (${_1}) ${_2}`, ['@tag', '@multiple_tags'], [`I search for "${_0}" and get results from 500 books`,`I should see search results with these metrics:`,`next I should see search results with these metrics:`]); + await gherkinStep(`I search for "${_0}" and get results from 500 books`, state, 129, 1); + await gherkinStep(`I should see search results with these metrics:`, state, 130, 2, undefined, [["Book Importance","Match Quality","score"],["primary","exact","3+5=8"]]); + await gherkinStep(`next I should see search results with these metrics:`, state, 133, 3, undefined, [["secondary","exact","2+5=7"]]); + await afterScenario(state); + } + ); + + test.for([ + {"_0":"\\'a\\'","_1":"\\`b\\`","_2":"\\\"c\\\""}, + {"_0":"${a}","_1":"\\${b}","_2":"\\${c}"}, + {"_0":"`a`","_1":"\\`b\\`","_2":"\\`c\\`"} + ])( + 'Scenario Outline: `someone` is ${sneaky} \\${with} \\\\${backslashes} \\\\\\${and} \\$\\{other} \\`things\\\\` \'like\' \\\'quotes\\\\\' (@tag @multiple_tags)', + async ({ _0, _1, _2 }, context) => { + let state = await initRuleScenario(context, `\`someone\` is \$\{sneaky} \\\$\{with} \\\\\$\{backslashes} \\\\\\\$\{and} \\$\\{other} \\\`things\\\\\` 'like' \\'quotes\\\\'`, ['@tag', '@multiple_tags'], [`a "string with \\"quotes\\""`,`\`someone\` is \$\{sneaky} \\\$\{with} \\\\\$\{backslashes} \\\\\\\$\{and} \\$\\{other} \\\`things\\\\\` 'like' \\'quotes\\\\'`]); + await gherkinStep(`a "string with \\"quotes\\""`, state, 143, 1); + await gherkinStep(`\`someone\` is \$\{sneaky} \\\$\{with} \\\\\$\{backslashes} \\\\\\\$\{and} \\$\\{other} \\\`things\\\\\` 'like' \\'quotes\\\\'`, state, 144, 2); + await afterScenario(state); + } + ); + + test('Scenario: `someone` is ${sneaky} \\${with} \\\\${backslashes} \\\\\\${and} \\$\\{other} \\`things\\\\` \'like\' \\\'quotes\\\\\' (@tag @multiple_tags)', async (context) => { + let state = await initRuleScenario(context, '`someone` is ${sneaky} \\${with} \\\\${backslashes} \\\\\\${and} \\$\\{other} \\`things\\\\` \'like\' \\\'quotes\\\\\'', ['@tag', '@multiple_tags'], [`a "string with \\"quotes\\""`,`\`someone\` is \$\{sneaky} \\\$\{with} \\\\\$\{backslashes} \\\\\\\$\{and} \\$\\{other} \\\`things\\\\\` 'like' \\'quotes\\\\'`]); + await gherkinStep(`a "string with \\"quotes\\""`, state, 152, 1); + await gherkinStep(`\`someone\` is \$\{sneaky} \\\$\{with} \\\\\$\{backslashes} \\\\\\\$\{and} \\$\\{other} \\\`things\\\\\` 'like' \\'quotes\\\\'`, state, 153, 2); await afterScenario(state); }); diff --git a/packages/main/src/render.ts b/packages/main/src/render.ts index f584309..ae934d8 100644 --- a/packages/main/src/render.ts +++ b/packages/main/src/render.ts @@ -148,11 +148,6 @@ function renderScenario(child:FeatureChild, config:QuickPickleConfig, tags:strin let tagTextForVitest = tags.length ? ` (${tags.join(' ')})` : '' - let steps = child.scenario?.steps.map((step,idx) => { - let text = step.text.replace(/`/g, '\\`') - return text - }) || [] - // For Scenario Outlines with examples if (child.scenario!.examples?.[0]?.tableHeader && child.scenario!.examples?.[0]?.tableBody) { @@ -169,13 +164,15 @@ function renderScenario(child:FeatureChild, config:QuickPickleConfig, tags:strin } let describe = q(replaceParamNames(child.scenario?.name ?? '')) - let name = replaceParamNames(child.scenario?.name ?? '', true).replace(/`/g, '\`') + let scenarioNameWithReplacements = tl(replaceParamNames(child.scenario?.name ?? '', true)) - let examples = steps.map((text,idx) => { + let examples = child.scenario?.steps.map(({text},idx) => { text = replaceParamNames(text,true) return text }) + let renderedSteps = renderSteps(child.scenario!.steps.map(s => ({...s, text: replaceParamNames(s.text, true)})), config, sp + ' ', isExploded ? `${explodedIdx+1}` : '') + return ` ${sp}test${attrs}.for([ ${sp} ${paramValues?.map(line => { @@ -184,12 +181,8 @@ ${sp} ${paramValues?.map(line => { ${sp}])( ${sp} '${q(child.scenario?.keyword || '')}: ${describe}${tagTextForVitest}', ${sp} async ({ ${origParamNames.map((p,i) => '_'+i)?.join(', ')} }, context) => { -${sp} let state = await ${initFn}(context, \`${name}\`, ['${tags.join("', '") || ''}'], [${examples?.map(s => '`'+s+'`').join(',')}]); -${child.scenario?.steps.map((step,idx) => { - let text = replaceParamNames(step.text,true).replace(/`/g, '\\`') - return `${sp} await gherkinStep(\`${text}\`, state, ${step.location.line}, ${idx+1}${isExploded ? `, ${explodedIdx + 1}` : ''});` -}).join('\n') -} +${sp} let state = await ${initFn}(context, ${scenarioNameWithReplacements}, ['${tags.join("', '") || ''}'], [${examples?.map(s => tl(s)).join(',')}]); +${renderedSteps} ${sp} await afterScenario(state); ${sp} } ${sp}); @@ -198,7 +191,7 @@ ${sp}); return ` ${sp}test${attrs}('${q(child.scenario!.keyword)}: ${q(child.scenario!.name)}${tagTextForVitest}', async (context) => { -${sp} let state = await ${initFn}(context, '${q(child.scenario!.name)}', ['${tags.join("', '") || ''}'], [${steps?.map(s => '`'+s+'`').join(',')}]); +${sp} let state = await ${initFn}(context, '${q(child.scenario!.name)}', ['${tags.join("', '") || ''}'], [${child.scenario?.steps.map(s => tl(s.text)).join(',')}]); ${renderSteps(child.scenario!.steps as Step[], config, sp + ' ', isExploded ? `${explodedIdx+1}` : '')} ${sp} await afterScenario(state); ${sp}}); @@ -214,14 +207,14 @@ function renderSteps(steps:Step[], config:QuickPickleConfig, sp = ' ', exploded let data = JSON.stringify(step.dataTable.rows.map(r => { return r.cells.map(c => c.value) })) - return `${sp}await gherkinStep('${q(step.text)}', state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});` + return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});` } else if (step.docString) { let data = JSON.stringify(pick(step.docString, ['content','mediaType'])) - return `${sp}await gherkinStep('${q(step.text)}', state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});` + return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});` } - return `${sp}await gherkinStep('${q(step.text)}', state, ${step.location.line}, ${minus}${idx+1}${explodedText ? `, ${explodedText}` : ''});` + return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}${explodedText ? `, ${explodedText}` : ''});` }).join('\n') } @@ -230,7 +223,27 @@ function renderSteps(steps:Step[], config:QuickPickleConfig, sp = ' ', exploded * @param t string * @returns string */ -const q = (t:string) => (t.replace(/'/g, "\\'")) +const q = (t:string) => (t.replace(/\\/g,'\\\\').replace(/'/g, "\\'")) + +/** + * Escapes text and returns a properly escaped template literal, + * since steps must be rendered in this way for Scenario Outlines + * + * For example: + * tl('escaped text') returns '`escaped text`' + * + * @param text string + * @returns string + */ +const tl = (text:string) => { + // Step 1: Escape existing escape sequences (e.g., \`) + text = text.replace(/\\/g, '\\\\'); + // Step 2: Escape backticks + text = text.replace(/`/g, '\\`'); + // Step 3: Escape $ if followed by { and not already escaped + text = text.replace(/\$\{(?!_\d+\})/g, '\\$\\{'); + return '`' + text + '`'; +} /** * Creates a 2d array of all possible combinations of the items in the input array diff --git a/packages/main/tests/index.test.ts b/packages/main/tests/index.test.ts index 9f5fe9c..c1d186b 100644 --- a/packages/main/tests/index.test.ts +++ b/packages/main/tests/index.test.ts @@ -55,8 +55,8 @@ Feature: Exploding Tags let output2 = await plugin.transform(feature2, 'test.feature') test('exploding tags work as expected', () => { expect(output2).toMatch(/test\.concurrent[\s\S]+?test\.concurrent/m) - expect(output2).toMatch(/I run the tests', state, 5, 1, 1\)/m) - expect(output2).toMatch(/I run the tests', state, 5, 1, 2\)/m) + expect(output2).toMatch(/I run the tests`, state, 5, 1, 1\)/m) + expect(output2).toMatch(/I run the tests`, state, 5, 1, 2\)/m) expect(output2).not.toMatch(/test\.concurrent[\s\S]+?test\.concurrent[/s/S]+?test\.concurrent/m) }) diff --git a/packages/main/tests/test.feature b/packages/main/tests/test.feature index 040528c..05b0479 100644 --- a/packages/main/tests/test.feature +++ b/packages/main/tests/test.feature @@ -117,3 +117,42 @@ Feature: Basic Test Example: This is a failing test Given I run the tests Then the tests should fail + + Rule: Tests must have appropriate guards against escaping + # | character 14 - 13 = 1 character 113 - 13 = 100 | + Example: `someone` is ${sneaky} \${with} \\${backslashes} \\\${and} \$\{other} \`things\\` 'like' \'quotes\\' + When I set the data variable "escapedDoubleQuote" to "\"" + And I set the data variable "escapedSingleQuote" to "\'" + And I set the data variable "escapedBacktick" to "\`" + And I set the data variable "doubleEscapedDoubleQuote" to "\\'" + And I set the data variable "doubleEscapedSingleQuote" to "\\'" + And I set the data variable "doubleEscapedBacktick" to "\\`" + And I set the data variable "unescapedError" to "${throw new Error(muuahahaha!)}" + And I set the data variable "escapedError" to "\${throw new Error(muuahahaha!)}" + Then the variable "data.escapedDoubleQuote" should be 1 character long + And the variable "data.escapedSingleQuote" should be 1 character long + And the variable "data.escapedBacktick" should be 2 characters long + And the variable "data.doubleEscapedDoubleQuote" should be 2 character long + And the variable "data.doubleEscapedSingleQuote" should be 2 character long + And the variable "data.doubleEscapedBacktick" should be 3 characters long + And the variable "data.unescapedError" should be 31 characters long + And the variable "data.escapedError" should be 32 characters long + And the variable "info.scenario" should be 100 characters long + + Example: Outside a {string} variable, escaped quotes are two characters, but the file is still safe + When I raw set the data variable "escapedDoubleQuote" to \" + And I raw set the data variable "escapedSingleQuote" to \' + And I raw set the data variable "escapedBacktick" to \` + And I raw set the data variable "doubleEscapedDoubleQuote" to \\" + And I raw set the data variable "doubleEscapedSingleQuote" to \\' + And I raw set the data variable "doubleEscapedBacktick" to \\` + And I raw set the data variable "unescapedError" to ${throw new Error(muuahahaha!)} + And I raw set the data variable "escapedError" to \${throw new Error(muuahahaha!)} + Then the variable "data.escapedDoubleQuote" should be 2 characters long + And the variable "data.escapedSingleQuote" should be 2 characters long + And the variable "data.escapedBacktick" should be 2 characters long + And the variable "data.doubleEscapedDoubleQuote" should be 3 character long + And the variable "data.doubleEscapedSingleQuote" should be 3 character long + And the variable "data.doubleEscapedBacktick" should be 3 characters long + And the variable "data.unescapedError" should be 31 characters long + And the variable "data.escapedError" should be 32 characters long diff --git a/packages/main/tests/tests.steps.ts b/packages/main/tests/tests.steps.ts index 3c6bead..4f047be 100644 --- a/packages/main/tests/tests.steps.ts +++ b/packages/main/tests/tests.steps.ts @@ -1,7 +1,7 @@ import { expect } from "vitest"; import { Given, Then, When } from "../src"; import type { DataTable } from "@cucumber/cucumber"; -import { clone, get } from "lodash-es"; +import { clone, get, set } from "lodash-es"; import type { DocString } from "../src/models/DocString"; Given("I run the tests", () => {}); @@ -39,6 +39,14 @@ Then('the sum should be {int}', (world, int) => { expect(world.numbers.reduce((a,b) => a + b, 0)).toBe(int) }) +When('I set the data variable/value/property {string} to {string}', (world, prop, value) => { + if (!world.data) world.data = {} + set(world.data, prop, value) +}) +When('I raw set the data variable/value/property {string} to {}', (world, prop, value) => { + if (!world.data) world.data = {} + set(world.data, prop, value) +}) Then('the variable/value/property/typeof {string} should include/contain/equal/match/be {string}', (world, prop, expected) => { let value = get(world,prop) let testValue = world.info.step.match(/^the typeof/) ? typeof value : value @@ -46,6 +54,10 @@ Then('the variable/value/property/typeof {string} should include/contain/equal/m if (world.info.step.match(/" should (?:equal|match|be)"/)) expect(testValue?.toString() ?? '').toBe(expected) else expect(testValue?.toString() ?? '').toContain(expected) }) +Then('the variable/value/property {string} should be {int} character(s) long', (world, prop, length) => { + let value = get(world,prop) + expect(value?.toString()?.length).toBe(parseInt(length)) +}) Then('the stack for error {int} should contain {string}', async (world, idx, expected) => { let stack = world.info.errors[idx-1].stack