quickpickle
Version:
Plugin for Vitest to run tests written in Gherkin Syntax.
1,120 lines (1,094 loc) • 41.5 kB
JavaScript
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
;