'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var cucumberExpressions = require('@cucumber/cucumber-expressions'); var lodashEs = require('lodash-es'); var parse = require('@cucumber/tag-expressions'); var Gherkin = require('@cucumber/gherkin'); var Messages = require('@cucumber/messages'); var cucumber = require('@cucumber/cucumber'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var Gherkin__namespace = /*#__PURE__*/_interopNamespaceDefault(Gherkin); var Messages__namespace = /*#__PURE__*/_interopNamespaceDefault(Messages); const steps = []; const expressionFactory = new cucumberExpressions.ExpressionFactory(new cucumberExpressions.ParameterTypeRegistry()); const addStepDefinition = (expression, f) => { const cucumberExpression = expressionFactory.createExpression(expression); steps.push({ expression, f, cucumberExpression }); }; const findStepDefinitionMatches = (step) => { return steps.reduce((accumulator, stepDefinition) => { const matches = stepDefinition.cucumberExpression.match(step); if (matches) { return [...accumulator, { stepDefinition, parameters: matches.map((match) => match.getValue()) }]; } else { return accumulator; } }, []); }; const findStepDefinitionMatch = (step) => { const stepDefinitionMatches = findStepDefinitionMatches(step); if (!stepDefinitionMatches || stepDefinitionMatches.length === 0) { throw new Error(`Undefined. Implement with the following snippet: Given('${step}', (world, ...params) => { // Write code here that turns the phrase above into concrete actions throw new Error('Not yet implemented!'); return state; }); `); } if (stepDefinitionMatches.length > 1) { throw new Error(`More than one step which matches: '${step}'`); } return stepDefinitionMatches[0]; }; function normalizeTags(tags) { if (!tags) return []; tags = Array.isArray(tags) ? tags : tags.split(/\s*,\s*/g); return tags.filter(Boolean).map(tag => tag.startsWith('@') ? tag : `@${tag}`); } /** * * @param confTags string[] * @param testTags string[] * @returns boolean */ function tagsMatch(confTags, testTags) { let tags = lodashEs.intersection(confTags.map(t => t.toLowerCase()), testTags.map(t => t.toLowerCase())); return tags?.length ? tags : null; } const parseTagsExpression = (tagsExpression) => { try { const parsedExpression = parse(tagsExpression); return parsedExpression; } catch (error) { throw new Error(`Failed to parse tag expression: ${error.message}`); } }; function tagsFunction(tagsExpression) { if (!tagsExpression) { return () => true; } const parsedTagsExpression = parseTagsExpression(tagsExpression); return (tags) => { const result = parsedTagsExpression.evaluate(tags); return result; }; } const allHooks = { beforeAll: [], before: [], beforeStep: [], afterAll: [], after: [], afterStep: [], }; const applyHooks = async (hooksName, state) => { const hooks = allHooks[hooksName]; for (let i = 0; i < hooks.length; i++) { let hook = hooks[i]; const result = hook.tagsFunction(state.info.tags.map((t) => t.toLowerCase())); if (result) { await hook.f(state); } } return state; }; const addHook = (hooksName, p1, p2) => { let hook = { name: '', f: async () => { }, tags: '', tagsFunction: () => true }; if (lodashEs.isFunction(p1)) hook = { ...hook, f: p1 }; else if (lodashEs.isString(p1)) hook.tags = p1; else hook = { ...hook, ...p1 }; if (lodashEs.isFunction(p2)) hook.f = p2; if (!hook.f) throw new Error('Function required: ' + JSON.stringify({ p1, p2 })); hook.tagsFunction = tagsFunction(hook.tags.toLowerCase()); allHooks[hooksName] = lodashEs.concat(allHooks[hooksName], hook); }; const BeforeAll = (p1, p2) => { addHook('beforeAll', p1, p2); }; const Before = (p1, p2) => { addHook('before', p1, p2); }; const BeforeStep = (p1, p2) => { addHook('beforeStep', p1, p2); }; const AfterAll = (p1, p2) => { addHook('afterAll', p1, p2); }; const After = (p1, p2) => { addHook('after', p1, p2); }; const AfterStep = (p1, p2) => { addHook('afterStep', p1, p2); }; const uuidFn = Messages__namespace.IdGenerator.uuid(); const builder = new Gherkin__namespace.AstBuilder(uuidFn); const gherkinMatcher = new Gherkin__namespace.GherkinClassicTokenMatcher(); const gherkinParser = new Gherkin__namespace.Parser(builder, gherkinMatcher); const mdMatcher = new Gherkin__namespace.GherkinInMarkdownTokenMatcher(); const mdParser = new Gherkin__namespace.Parser(builder, mdMatcher); function renderGherkin(src, config, isMarkdown) { // Parse the raw file into a GherkinDocument const gherkinDocument = isMarkdown ? mdParser.parse(src) : gherkinParser.parse(src); // Exit if there's no feature or scenarios if (!gherkinDocument?.feature || !gherkinDocument.feature?.children?.length) { return ''; } return `// Generated by quickpickle import { test, describe, beforeAll, afterAll } from 'vitest'; import { gherkinStep, applyHooks, getWorldConstructor, } from 'quickpickle'; let World = getWorldConstructor() const common = { info: { feature: '${q(gherkinDocument.feature.keyword)}: ${q(gherkinDocument.feature.name)}', tags: ${JSON.stringify(normalizeTags(gherkinDocument.feature.tags.map(t => t.name)))} }}; beforeAll(async () => { await applyHooks('beforeAll', common); }); afterAll(async () => { await applyHooks('afterAll', common); }); const afterScenario = async(state) => { await applyHooks('after', state); } ${renderFeature(gherkinDocument.feature, config)} `; } function renderFeature(feature, config) { // Get the feature tags let tags = feature.tags.map(t => t.name); // Get the background stes and all the scenarios let { backgroundSteps, children } = renderChildren(feature.children, config, tags); let featureName = `${q(feature.keyword)}: ${q(feature.name)}`; // Render the initScenario function, which will be called at the beginning of each scenario return ` const initScenario = async(context, scenario, tags, steps) => { let state = new World(context, { feature:'${featureName}', scenario, tags, steps, common, config:${JSON.stringify(config)}}, ${JSON.stringify(config.worldConfig)}); await state.init(); state.common = common; state.info.feature = '${featureName}'; state.info.scenario = scenario; state.info.tags = [...tags]; await applyHooks('before', state); ${backgroundSteps} return state; } describe('${q(feature.keyword)}: ${q(feature.name)}', () => { ${children} });`; } function isRule(child) { return child.hasOwnProperty('rule'); } function renderChildren(children, config, tags, sp = ' ') { const output = { backgroundSteps: '', children: '', }; if (!children.length) return output; if (children[0].hasOwnProperty('background')) { output.backgroundSteps = renderSteps(children.shift().background.steps, config, sp, '', true); } for (let child of children) { if (isRule(child)) { output.children += renderRule(child, config, tags, sp); } else if (child.hasOwnProperty('scenario')) { output.children += renderScenario(child, config, tags, sp); } } return output; } function renderRule(child, config, tags, sp = ' ') { tags = [...tags, ...child.rule.tags.map(t => t.name)]; let { backgroundSteps, children } = renderChildren(child.rule.children, config, tags, sp + ' '); return ` ${sp}describe('${q(child.rule.keyword)}: ${q(child.rule.name)}', () => { ${sp} const initRuleScenario = async (context, scenario, tags, steps) => { ${sp} let state = await initScenario(context, scenario, tags, steps); ${sp} state.info.rule = '${q(child.rule.name)}'; ${backgroundSteps} ${sp} return state; ${sp} } ${children} ${sp}}); `; } function renderScenario(child, config, tags, sp = ' ') { let initFn = sp.length > 2 ? 'initRuleScenario' : 'initScenario'; tags = [...tags, ...child.scenario.tags.map(t => t.name)]; let todo = tagsMatch(config.todoTags, tags) ? '.todo' : ''; let skip = tagsMatch(config.skipTags, tags) ? '.skip' : ''; let fails = tagsMatch(config.failTags, tags) ? '.fails' : ''; let sequential = tagsMatch(config.sequentialTags, tags) ? '.sequential' : ''; let concurrent = (!sequential && tagsMatch(config.concurrentTags, tags)) ? '.concurrent' : ''; let attrs = todo + skip + fails + concurrent + sequential; // Deal with exploding tags let taglists = explodeTags(config.explodeTags, tags); let isExploded = taglists.length > 1 ? true : false; return taglists.map((tags, explodedIdx) => { let tagTextForVitest = tags.length ? ` (${tags.join(' ')})` : ''; // For Scenario Outlines with examples if (child.scenario.examples?.[0]?.tableHeader && child.scenario.examples?.[0]?.tableBody) { let origParamNames = child.scenario?.examples?.[0]?.tableHeader?.cells?.map(c => c.value) || []; let paramValues = child.scenario?.examples?.[0].tableBody.map((r) => { return lodashEs.fromPairs(r.cells.map((c, i) => ['_' + i, c.value])); }); function replaceParamNames(t, withBraces) { origParamNames.forEach((p, i) => { t = t.replace(new RegExp(`<${lodashEs.escapeRegExp(p)}>`, 'g'), (withBraces ? `$\{_${i}\}` : `$_${i}`)); }); return t; } let describe = q(replaceParamNames(child.scenario?.name ?? '')); let scenarioNameWithReplacements = tl(replaceParamNames(child.scenario?.name ?? '', true)); 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 => { return JSON.stringify(line); }).join(',\n' + sp + ' ')} ${sp}])( ${sp} '${q(child.scenario?.keyword || '')}: ${describe}${tagTextForVitest}', ${sp} async ({ ${origParamNames.map((p, i) => '_' + i)?.join(', ')} }, context) => { ${sp} let state = await ${initFn}(context, ${scenarioNameWithReplacements}, ['${tags.join("', '") || ''}'], [${examples?.map(s => tl(s)).join(',')}]); ${renderedSteps} ${sp} await afterScenario(state); ${sp} } ${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("', '") || ''}'], [${child.scenario?.steps.map(s => tl(s.text)).join(',')}]); ${renderSteps(child.scenario.steps, config, sp + ' ', isExploded ? `${explodedIdx + 1}` : '')} ${sp} await afterScenario(state); ${sp}}); `; }).join('\n\n'); } function renderSteps(steps, config, sp = ' ', explodedText = '', isBackground = false) { let minus = isBackground ? '-' : ''; return steps.map((step, idx) => { if (step.dataTable) { let data = JSON.stringify(step.dataTable.rows.map(r => { return r.cells.map(c => c.value); })); 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(lodashEs.pick(step.docString, ['content', 'mediaType'])); return `${sp}await gherkinStep(${tl(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 ? `, ${explodedText}` : ''});`; }).join('\n'); } /** * Escapes quotation marks in a string for the purposes of this rendering function. * @param t string * @returns string */ const q = (t) => (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) => { // 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 * @param arr A 2d array of strings * @returns A 2d array of all possible combinations of the items in the input array */ function explodeArray(arr) { if (arr.length === 0) return [[]]; arr = arr.map(subArr => { return subArr.length ? subArr : ['']; }); const [first, ...rest] = arr; const subCombinations = explodeArray(rest); return first.flatMap(item => subCombinations.map(subCombo => [item, ...subCombo].filter(Boolean))); } /** * This function "explodes" any tags in the "explodeTags" setting and returns all possible * combinations of all the tags. The theory is that it allows you to write one Scenario that * runs multiple times in different ways; e.g. with and without JS or in different browsers. * * To take this case as an example, if the explodeTags are: * ``` * [ * ['nojs', 'js'], * ['firefox', 'chromium', 'webkit'], * ] * ``` * * And the testTags are: * ``` * ['nojs', 'js', 'snapshot'] * ``` * * Then the function will return: * ``` * [ * ['nojs', 'snapshot'], * ['js', 'snapshot'], * ] * ``` * * In that case, the test will be run twice. * * @param explodeTags the 2d array of tags that should be exploded * @param testTags the tags to test against * @returns a 2d array of all possible combinations of tags */ function explodeTags(explodeTags, testTags) { if (!explodeTags.length) return [testTags]; let tagsToTest = [...testTags]; // gather a 2d array of items that are shared between tags and each array in explodeTags // and then remove those items from the tags array const sharedTags = explodeTags.map(tagList => { let items = tagList.filter(tag => tagsToTest.includes(tag)); if (items.length) items.forEach(item => tagsToTest.splice(tagsToTest.indexOf(item), 1)); return items; }); // then, build a 2d array of all possible combinations of the shared tags let combined = explodeArray(sharedTags); // finally, return the list return combined.length ? combined.map(arr => [...tagsToTest, ...arr]) : [testTags]; } class DocString extends String { constructor(content, mediaType = '') { super(content); this.mediaType = mediaType; } toString() { return this.valueOf(); } [Symbol.toPrimitive](hint) { if (hint === 'number') { return Number(this.valueOf()); } return this.valueOf(); } } class QuickPickleWorld { constructor(context, info) { this.context = context; this.common = info.common; this.info = { ...info, errors: [] }; } async init() { } get config() { return this.info.config; } get worldConfig() { return this.info.config.worldConfig; } get isComplete() { return this.info.stepIdx === this.info.steps.length; } tagsMatch(tags) { return tagsMatch(tags, this.info.tags); } toString() { let parts = [ this.constructor.name, this.info.feature, this.info.scenario + (this.info.explodedIdx ? ` (${this.info.tags.join(',')})` : ''), `${this.info.stepIdx?.toString().padStart(2, '0')} ${this.info.step}`, ]; return parts.join('_'); } } let worldConstructor = QuickPickleWorld; function getWorldConstructor() { return worldConstructor; } function setWorldConstructor(constructor) { worldConstructor = constructor; } const featureRegex = /\.feature(?:\.md)?$/; const Given = addStepDefinition; const When = addStepDefinition; const Then = addStepDefinition; function formatStack(text, line) { let stack = text.split('\n'); while (!stack[0].match(/\.feature(?:\.md)?:\d+:\d+/)) stack.shift(); stack[0] = stack[0].replace(/:\d+:\d+$/, `:${line}:1`); return stack.join('\n'); } const gherkinStep = async (step, state, line, stepIdx, explodeIdx, data) => { const stepDefinitionMatch = findStepDefinitionMatch(step); // Set the state info state.info.step = step; state.info.line = line; state.info.stepIdx = stepIdx; state.info.explodedIdx = explodeIdx; // Sort out the DataTable or DocString if (Array.isArray(data)) { data = new cucumber.DataTable(data); } else if (data?.hasOwnProperty('content')) { data = new DocString(data.content, data.mediaType); } try { await applyHooks('beforeStep', state); try { await stepDefinitionMatch.stepDefinition.f(state, ...stepDefinitionMatch.parameters, data); } catch (e) { // Add the Cucumber info to the error message e.message = `${step} (#${line})\n${e.message}`; // Sort out the stack for the Feature file e.stack = formatStack(e.stack, state.info.line); // Set the flag that this error has been added to the state e.isStepError = true; // Add the error to the state state.info.errors.push(e); // If not in a soft fail mode, re-throw the error if (state.isComplete || !state.tagsMatch(state.config.softFailTags)) throw e; } finally { await applyHooks('afterStep', state); } } catch (e) { // If the error hasn't already been added to the state: if (!e.isStepError) { // Add the Cucumber info to the error message e.message = `${step} (#${line})\n${e.message}`; // Add the error to the state state.info.errors.push(e); } // If in soft fail mode and the state is not complete, don't throw the error if (state.tagsMatch(state.config.softFailTags) && (!state.isComplete || !state.info.errors.length)) return; // The After hook is usually run in the rendered file, at the end of the rendered steps. // But, if the tests have failed, then it should run here, since the test is halted. await applyHooks('after', state); // Otherwise throw the error throw e; } finally { if (state.info.errors.length && state.isComplete) { let error = state.info.errors[state.info.errors.length - 1]; error.message = `Scenario finished with ${state.info.errors.length} errors:\n\n${state.info.errors.map((e) => e?.message || '(no error message)').reverse().join('\n\n')}`; throw error; } } }; const defaultConfig = { /** * Tags to mark as todo, using Vitest's `test.todo` implementation. */ todoTags: ['@todo', '@wip'], /** * Tags to skip, using Vitest's `test.skip` implementation. */ skipTags: ['@skip'], /** * Tags to mark as failing, using Vitest's `test.failing` implementation. */ failTags: ['@fails', '@failing'], /** * Tags to mark as soft failing, allowing further steps to run until the end of the scenario. */ softFailTags: ['@soft', '@softfail'], /** * Tags to run in parallel, using Vitest's `test.concurrent` implementation. */ concurrentTags: ['@concurrent'], /** * Tags to run sequentially, using Vitest's `test.sequential` implementation. */ sequentialTags: ['@sequential'], /** * Explode tags into multiple tests, e.g. for different browsers. */ explodeTags: [], /** * The config for the World class. Must be serializable with JSON.stringify. * Not used by the default World class, but may be used by plugins or custom * implementations, like @quickpickle/playwright. */ worldConfig: {} }; function is2d(arr) { return Array.isArray(arr) && arr.every(item => Array.isArray(item)); } const quickpickle = (conf = {}) => { let config; let passedConfig = { ...conf }; return { name: 'quickpickle-transform', configResolved(resolvedConfig) { config = lodashEs.defaultsDeep(lodashEs.get(resolvedConfig, 'test.quickpickle') || {}, lodashEs.get(resolvedConfig, 'quickpickle') || {}, passedConfig, defaultConfig); config.todoTags = normalizeTags(config.todoTags); config.skipTags = normalizeTags(config.skipTags); config.failTags = normalizeTags(config.failTags); config.softFailTags = normalizeTags(config.softFailTags); config.concurrentTags = normalizeTags(config.concurrentTags); config.sequentialTags = normalizeTags(config.sequentialTags); if (is2d(config.explodeTags)) config.explodeTags = config.explodeTags.map(normalizeTags); else config.explodeTags = [normalizeTags(config.explodeTags)]; }, async transform(src, id) { if (featureRegex.test(id)) { return renderGherkin(src, config, id.match(/\.md$/) ? true : false); } }, }; }; Object.defineProperty(exports, 'DataTable', { enumerable: true, get: function () { return cucumber.DataTable; } }); exports.After = After; exports.AfterAll = AfterAll; exports.AfterStep = AfterStep; exports.Before = Before; exports.BeforeAll = BeforeAll; exports.BeforeStep = BeforeStep; exports.DocString = DocString; exports.Given = Given; exports.QuickPickleWorld = QuickPickleWorld; exports.Then = Then; exports.When = When; exports.applyHooks = applyHooks; exports.default = quickpickle; exports.defaultConfig = defaultConfig; exports.explodeTags = explodeTags; exports.formatStack = formatStack; exports.getWorldConstructor = getWorldConstructor; exports.gherkinStep = gherkinStep; exports.normalizeTags = normalizeTags; exports.quickpickle = quickpickle; exports.setWorldConstructor = setWorldConstructor; exports.tagsMatch = tagsMatch; //# sourceMappingURL=index.cjs.map