UNPKG

quickpickle

Version:

Plugin for Vitest to run tests written in Gherkin Syntax.

1,120 lines (1,094 loc) 41.5 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var cucumberExpressions = require('@cucumber/cucumber-expressions'); var get = require('lodash/get'); var defaultsDeep = require('lodash/defaultsDeep'); var isFunction = require('lodash/isFunction'); var isString = require('lodash/isString'); var concat = require('lodash/concat'); var sortBy = require('lodash/sortBy'); var intersection = require('lodash/intersection'); var parse = require('@cucumber/tag-expressions'); var Gherkin = require('@cucumber/gherkin'); var Messages = require('@cucumber/messages'); var fromPairs = require('lodash/fromPairs'); var escapeRegExp = require('lodash/escapeRegExp'); var pixelmatch = require('pixelmatch'); var ariaRoles = require('@a11y-tools/aria-roles'); 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 parameterTypeRegistry = new cucumberExpressions.ParameterTypeRegistry(); const expressionFactory = new cucumberExpressions.ExpressionFactory(parameterTypeRegistry); const cucumberExpressionGenerator = new cucumberExpressions.CucumberExpressionGenerator(() => parameterTypeRegistry.parameterTypes); const buildParameterType = (type) => { return new cucumberExpressions.ParameterType(type.name, type.regexp, null, type.transformer, type.useForSnippets ?? true, type.preferForRegexpMatch ?? false); }; const defineParameterType = (parameterType) => { parameterTypeRegistry.defineParameterType(buildParameterType(parameterType)); }; 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, snippetData) => { const stepDefinitionMatches = findStepDefinitionMatches(step); // If it's not found if (!stepDefinitionMatches || stepDefinitionMatches.length === 0) { let snippet = getSnippet(step, snippetData); throw new Error(`Undefined. Implement with the following snippet: ${snippet} `); } if (stepDefinitionMatches.length > 1) { throw new Error(`More than one step which matches: '${step}'`); } return stepDefinitionMatches[0]; }; function getSnippet(step, { stepType, dataType }) { const generatedExpressions = cucumberExpressionGenerator.generateExpressions(step); const stepParameterNames = dataType ? [dataType] : []; let functionName = 'Given'; if (stepType === 'Action') functionName = 'When'; if (stepType === 'Outcome') functionName = 'Then'; let implementation = "throw new Error('Not yet implemented')"; const definitionChoices = generatedExpressions.map((generatedExpression, index) => { const prefix = index === 0 ? '' : '// '; const allParameterNames = ['world'].concat(generatedExpression.parameterNames, stepParameterNames); return `${prefix + functionName}('${escapeSpecialCharacters(generatedExpression)}', async function (${allParameterNames.join(', ')}) {\n`; }); return (`${definitionChoices.join('')} // Write code here that turns the phrase above into concrete actions\n` + ` ${implementation}\n` + '});'); } function escapeSpecialCharacters(generatedExpression) { let source = generatedExpression.source; // double up any backslashes because we're in a javascript string source = source.replace(/\\/g, '\\\\'); // escape any single quotes because that's our quote delimiter source = source.replace(/'/g, "\\'"); return source; } 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}`); } /** * Compares two lists of tags and returns the ones that are shared by both, * or null if there are no shared tags. * * @param confTags string[] * @param testTags string[] * @returns string[]|null */ function tagsMatch(confTags, testTags) { let tags = 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 = sortBy(allHooks[hooksName], 'weight'); 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, weight: 0 }; if (isFunction(p1)) hook = { ...hook, f: p1 }; else if (isString(p1)) hook.tags = p1; else hook = { ...hook, ...p1 }; if (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] = 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) { return `import { test } from 'vitest' test.skip('')`; } 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, children: `${sp} test.skip('');` }; 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 fromPairs(r.cells.map((c, i) => ['_' + i, c.value])); }); const replaceParamNames = (t, withBraces) => { origParamNames.forEach((p, i) => { t = t.replace(new RegExp(`<${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}` : '', false, replaceParamNames); 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, replaceParamNames = (t) => t) { let minus = isBackground ? '-' : ''; return steps.map((step, idx) => { if (step.dataTable) { let data = `[${step.dataTable.rows.map(r => `[${r.cells.map(c => tl(replaceParamNames(c.value, true))).join(',')}]`).join(',')}]`; return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx + 1}, ${explodedText || 'undefined'}, ${data});`; } else if (step.docString) { let data = `{content:${tl(replaceParamNames(step.docString.content, true))}, mediaType:${step.docString?.mediaType ? tl(step.docString.mediaType) : 'null'} }`; return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx + 1}, ${explodedText || 'undefined'}, ${data});`; } return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx + 1}${explodedText ? `, ${explodedText}` : ''});`; }).join('\n'); } function getStepType(steps, idx) { switch (steps[idx].keywordType) { case 'Context': case 'Action': case 'Outcome': return steps[idx].keywordType; default: if (idx) return getStepType(steps, idx - 1); return 'Context'; } } /** * 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 DataTable { constructor(sourceTable) { if (sourceTable instanceof Array) { this.rawTable = sourceTable; } else { this.rawTable = sourceTable.rows.map((row) => row.cells.map((cell) => cell.value)); } } /** * This method returns an array of objects of the shape { [key: string]: string }. * It is intended for tables with a header row, as follows: * * ``` * | id | name | color | taste | * | 1 | apple | red | sweet | * | 2 | banana | yellow | sweet | * | 3 | orange | orange | sour | * ``` * * This would return the following array of objects: * * ``` * [ * { id: '1', name: 'apple', color: 'red', taste: 'sweet' }, * { id: '2', name: 'banana', color: 'yellow', taste: 'sweet' }, * { id: '3', name: 'orange', color: 'orange', taste: 'sour' }, * ] * ``` * * @returns Record<string, string>[] */ hashes() { const copy = this.raw(); const keys = copy[0]; const valuesArray = copy.slice(1); return valuesArray.map((values) => { const rowObject = {}; keys.forEach((key, index) => (rowObject[key] = values[index])); return rowObject; }); } /** * This method returns the raw table as a two-dimensional array. * It can be used for tables with or without a header row, for example: * * ``` * | id | name | color | taste | * | 1 | apple | red | sweet | * | 2 | banana | yellow | sweet | * | 3 | orange | orange | sour | * ``` * * would return the following array of objects: * * ``` * [ * ['id', 'name', 'color', 'taste'], * ['1', 'apple', 'red', 'sweet'], * ['2', 'banana', 'yellow', 'sweet'], * ['3', 'orange', 'orange', 'sour'], * ] * ``` * * @returns string[][] */ raw() { return this.rawTable.slice(0); } /** * This method is intended for tables with a header row, and returns * the value rows as a two-dimensional array, without the header row: * * ``` * | id | name | color | taste | * | 1 | apple | red | sweet | * | 2 | banana | yellow | sweet | * | 3 | orange | orange | sour | * ``` * * would return the following array of objects: * * ``` * [ * ['1', 'apple', 'red', 'sweet'], * ['2', 'banana', 'yellow', 'sweet'], * ['3', 'orange', 'orange', 'sour'], * ] * ``` * * @returns string[][] */ rows() { const copy = this.raw(); copy.shift(); return copy; } /** * This method is intended for tables with exactly two columns. * It returns a single object with the first column as keys and * the second column as values. * * ``` * | id | 1 | * | name | apple | * | color | red | * | taste | sweet | * ``` * * would return the following object: * * ``` * { * id: '1', * name: 'apple', * color: 'red', * taste: 'sweet', * } * ``` * * @returns Record<string, string> */ rowsHash() { const rows = this.raw(); const everyRowHasTwoColumns = rows.every((row) => row.length === 2); if (!everyRowHasTwoColumns) { throw new Error('rowsHash can only be called on a data table where all rows have exactly two columns'); } const result = {}; rows.forEach((x) => (result[x[0]] = x[1])); return result; } /** * This method transposes the DataTable, making the columns into rows * and vice versa. For example the following raw table: * * ``` * [ * ['1', 'apple', 'red', 'sweet'], * ['2', 'banana', 'yellow', 'sweet'], * ['3', 'orange', 'orange', 'sour'], * ] * ``` * * would be transposed to: * * ``` * [ * ['1', '2', '3'], * ['apple', 'banana', 'orange'], * ['red', 'yellow', 'orange'], * ['sweet', 'sweet', 'sour'], * ] * ``` * * @returns DataTable */ transpose() { const transposed = this.rawTable[0].map((x, i) => this.rawTable.map((y) => y[i])); return new DataTable(transposed); } } 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(); } } /** * Shim for node:path.normalize * @param path string * @returns string */ function normalize(path) { // Simple path normalization const isAbsolute = path.startsWith('/'); const parts = path.split(/[/\\]+/).filter(Boolean); const normalizedParts = []; for (const part of parts) { if (part === '.') continue; if (part === '..') { normalizedParts.pop(); } else { normalizedParts.push(part); } } return (isAbsolute ? '/' : '') + normalizedParts.join('/'); } /** * Shim for node:path.join * @param paths string[] * @returns string */ function join(...paths) { return normalize(paths.join('/')); } const DEFAULT_OPTIONS = { decode: [ { regex: /%2e/g, replacement: '.' }, { regex: /%2f/g, replacement: '/' }, { regex: /%5c/g, replacement: '\\' } ], parentDirectoryRegEx: /[\/\\]\.\.[\/\\]/g, notAllowedRegEx: /:|\$|!|'|"|@|\+|`|\||=/g }; /** * Sanitizes a portion of a path to avoid Path Traversal */ function sanitize(pathstr, options = DEFAULT_OPTIONS) { if (!options) options = DEFAULT_OPTIONS; if (typeof options !== 'object') throw new Error('options must be an object'); if (!Array.isArray(options.decode)) options.decode = DEFAULT_OPTIONS.decode; if (!options.parentDirectoryRegEx) options.parentDirectoryRegEx = DEFAULT_OPTIONS.parentDirectoryRegEx; if (!options.notAllowedRegEx) options.notAllowedRegEx = DEFAULT_OPTIONS.notAllowedRegEx; if (typeof pathstr !== 'string') { // Stringify the path pathstr = `${pathstr}`; } let sanitizedPath = pathstr; // ################################################################################################################ // Decode options.decode.forEach(decode => { sanitizedPath = sanitizedPath.replace(decode.regex, decode.replacement); }); // Remove not allowed characters sanitizedPath = sanitizedPath.replace(options.notAllowedRegEx, ''); // Replace backslashes with normal slashes sanitizedPath = sanitizedPath.replace(/[\\]/g, '/'); // Replace /../ with / sanitizedPath = sanitizedPath.replace(options.parentDirectoryRegEx, '/'); // Remove ../ at pos 0 and /.. at end sanitizedPath = sanitizedPath.replace(/^\.\.[\/\\]/g, '/'); sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.$/g, '/'); // Replace double (back)slashes with a single slash sanitizedPath = sanitizedPath.replace(/[\/\\]+/g, '/'); // Normalize path sanitizedPath = normalize(sanitizedPath); // Remove / or \ in the end while (sanitizedPath.endsWith('/') || sanitizedPath.endsWith('\\')) { sanitizedPath = sanitizedPath.slice(0, -1); } // Remove / or \ in the beginning while (sanitizedPath.startsWith('/') || sanitizedPath.startsWith('\\')) { sanitizedPath = sanitizedPath.slice(1); } // Validate path sanitizedPath = join('', sanitizedPath); // Remove not allowed characters sanitizedPath = sanitizedPath.replace(options.notAllowedRegEx, ''); // Again change all \ to / sanitizedPath = sanitizedPath.replace(/[\\]/g, '/'); // Replace double (back)slashes with a single slash sanitizedPath = sanitizedPath.replace(/[\/\\]+/g, '/'); // Replace /../ with / sanitizedPath = sanitizedPath.replace(options.parentDirectoryRegEx, '/'); // Remove ./ or / at start while (sanitizedPath.startsWith('/') || sanitizedPath.startsWith('./') || sanitizedPath.endsWith('/..') || sanitizedPath.endsWith('/../') || sanitizedPath.startsWith('../') || sanitizedPath.startsWith('/../')) { sanitizedPath = sanitizedPath.replace(/^\.\//g, ''); // ^./ sanitizedPath = sanitizedPath.replace(/^\//g, ''); // ^/ // Remove ../ | /../ at pos 0 and /.. | /../ at end sanitizedPath = sanitizedPath.replace(/^[\/\\]\.\.[\/\\]/g, '/'); sanitizedPath = sanitizedPath.replace(/^\.\.[\/\\]/g, '/'); sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.$/g, '/'); sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.\/$/g, '/'); } // Make sure out is not "." sanitizedPath = sanitizedPath.trim() === '.' ? '' : sanitizedPath; return sanitizedPath.trim(); } // PNG shim that works in both browser and Node.js environments // Uses dynamic imports instead of require() // Check if we're in a browser environment const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; // Create a promise that resolves to the PNG constructor let pngPromise; if (isBrowser) { // Browser environment - use browser build pngPromise = import('pngjs/browser').then(mod => mod.PNG); } else { // Node.js environment - use main build pngPromise = import('pngjs').then(mod => mod.PNG); } // Cache the PNG constructor once loaded let cachedPNG = null; async function getPNG() { if (!cachedPNG) { cachedPNG = await pngPromise; } return cachedPNG; } class QuickPickleWorld { constructor(context, info) { this._projectRoot = ''; this.data = {}; this.sanitizePath = sanitize; this.context = context; this.common = info.common; this.info = { ...info, errors: [] }; this._projectRoot = info.config.root; } 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; } get projectRoot() { return this._projectRoot; } /** * Checks the tags of the Scenario against a provided list of tags, * and returns the shared tags, with the "@" prefix character. * * @param tags tags to check * @returns string[]|null */ tagsMatch(tags) { return tagsMatch(tags, this.info.tags); } /** * Given a provided path-like string, returns a full path that: * * 1. contains no invalid characters. * 2. is a subdirectory of the project root. * * This is intended for security when retrieving and saving files; * it does not slugify filenames or check for a file's existence. * * @param path string the path to sanitize * @return string the sanitized path, including the project root */ fullPath(path) { return `${this._projectRoot}/${this.sanitizePath(path)}`; } /** * A helper function for when you really just need to wait. * * @deprecated Waiting for arbitrary amounts of time makes your tests flaky! There are * usually better ways to wait for something to happen, and this functionality will be * removed from the API as soon we're sure nobody will **EVER** want to use it again. * (That may be a long time.) * * @param ms milliseconds to wait */ async wait(ms) { await new Promise(r => setTimeout(r, ms)); } 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 defaultScreenshotComparisonOptions = { maxDiffPercentage: 0, threshold: 0.1, alpha: 0.6 }; class VisualWorld extends QuickPickleWorld { constructor(context, info) { super(context, info); } async init() { } get screenshotDir() { return this.sanitizePath(this.worldConfig.screenshotDir); } get screenshotFilename() { return `${this.toString().replace(/^.+?Feature: /, 'Feature: ').replace(' ' + this.info.step, '')}.png`; } get screenshotPath() { return this.fullPath(`${this.screenshotDir}/${this.screenshotFilename}`); } get screenshotOptions() { return defaultsDeep((this.worldConfig.screenshotOptions || {}), (this.worldConfig.screenshotOpts || {}), defaultScreenshotComparisonOptions); } getScreenshotPath(name) { if (!name) return this.screenshotPath; // If name is already a full absolute path (starts with project root), return as-is if (name.startsWith(this.projectRoot)) { return name; } // If name starts with the screenshot directory, remove it else if (name.startsWith(this.screenshotDir)) name = name.slice(this.screenshotDir.length); // If name already ends with .png, remove it const hasExtension = name.endsWith('.png'); const baseName = hasExtension ? name.slice(0, -4) : name; // Add the exploded tags if necessary let explodedTags = this.info.explodedIdx ? `_(${this.info.tags.join(',')})` : ''; if (name.includes(explodedTags)) explodedTags = ''; // Return the full path return this.fullPath(`${this.screenshotDir}/${baseName}${explodedTags}.png`); } async screenshotDiff(actual, expected, opts) { // Get the PNG constructor using the shim const PNG = await getPNG(); // Parse PNG images to get raw pixel data const actualPng = PNG.sync.read(actual); const expectedPng = PNG.sync.read(expected); const { width, height } = expectedPng; const diffPng = new PNG({ width, height }); try { const pixels = pixelmatch(actualPng.data, expectedPng.data, diffPng.data, width, height, opts); const pct = (pixels / (width * height)) * 100; return { diff: PNG.sync.write(diffPng), pixels, pct }; } catch (e) { e.message += `\n expected: w:${width}px h:${height}px ${expectedPng.data.length}b\n actual: w:${actualPng.width}px h:${actualPng.height}px ${actualPng.data.length}b`; throw e; } } } defineParameterType({ name: 'AriaRole', regexp: new RegExp(`(${(['element', 'input', ...Object.keys(ariaRoles.ariaRoles)]).join('|')})`), }); const featureRegex = /\.feature(?:\.md)?$/; const Given = addStepDefinition; const When = addStepDefinition; const Then = addStepDefinition; const stackRegex = /\.feature(?:\.md)?:\d+:\d+/; function formatStack(text, line) { if (!text.match(stackRegex)) return text; let stack = text.split('\n'); while (!stack[0].match(stackRegex)) stack.shift(); stack[0] = stack[0].replace(/:\d+:\d+$/, `:${line}:1`); return stack.join('\n'); } function raceTimeout(work, ms, errorMessage) { let timerId; const timeoutPromise = new Promise((_, reject) => { timerId = setTimeout(() => reject(new Error(errorMessage)), ms); }); // make sure to clearTimeout on either success *or* failure const wrapped = work.finally(() => clearTimeout(timerId)); return Promise.race([wrapped, timeoutPromise]); } const gherkinStep = async (stepType, step, state, line, stepIdx, explodeIdx, data) => { try { // Set the state info state.info.stepType = stepType; state.info.step = step; state.info.line = line; state.info.stepIdx = stepIdx; state.info.explodedIdx = explodeIdx; // Sort out the DataTable or DocString let dataType = ''; if (Array.isArray(data)) { data = new DataTable(data); dataType = 'dataTable'; } else if (data?.hasOwnProperty('content')) { data = new DocString(data.content, data.mediaType); dataType = 'docString'; } const promise = async () => { await applyHooks('beforeStep', state); try { const stepDefinitionMatch = findStepDefinitionMatch(step, { stepType, dataType }); 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); } }; await raceTimeout(promise(), state.config.stepTimeout, `Step timed out after ${state.config.stepTimeout}ms`); } 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 raceTimeout(applyHooks('after', state), state.config.stepTimeout, `After hook timed out after ${state.config.stepTimeout}ms`); // 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 = { /** * The root directory for the tests to run, from vite or vitest config */ root: '', /** * The maximum time in ms to wait for a step to complete. */ stepTimeout: 3000, /** * 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 = defaultsDeep(get(resolvedConfig, 'test.quickpickle') || {}, 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)]; if (!config.root) config.root = resolvedConfig.root; }, async transform(src, id) { if (featureRegex.test(id)) { return renderGherkin(src, config, id.match(/\.md$/) ? true : false); } }, }; }; exports.After = After; exports.AfterAll = AfterAll; exports.AfterStep = AfterStep; exports.Before = Before; exports.BeforeAll = BeforeAll; exports.BeforeStep = BeforeStep; exports.DataTable = DataTable; exports.DocString = DocString; exports.Given = Given; exports.QuickPickleWorld = QuickPickleWorld; exports.Then = Then; exports.VisualWorld = VisualWorld; exports.When = When; exports.applyHooks = applyHooks; exports.default = quickpickle; exports.defaultConfig = defaultConfig; exports.defaultScreenshotComparisonOptions = defaultScreenshotComparisonOptions; exports.defineParameterType = defineParameterType; 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