jest-bdd-generator
Version:
Jest code generator empowered with Gherkin and BDD
333 lines (302 loc) • 11.6 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @ttfe/@typescript-eslint/no-unnecessary-condition */
import type { AggregatedResult, AssertionResult } from '@jest/test-result';
import * as cucumber from '@cucumber/cucumber';
import { runCLI } from 'jest';
import { Step, Result, ExampleStep } from '../types';
// import { PickleStep } from '@cucumber/messages';
type StepContext = {
jest: {
stepIndex: Record<string, number>;
exampleIndex?: number;
parent: string;
jestMappedResult: AssertionResult;
};
};
type StepWithSanTitle = Step & {
/**
* see: https://github.com/cucumber/cucumber-js/blob/main/docs/custom_snippet_syntaxes.md
*/
sanitizedTitle: string;
};
function escapableWrapper(wrapper: string) {
const start = wrapper.charAt(0);
const end = wrapper.charAt(1) ?? start;
return `${start}(?:[^${end}]|\\\\${end})+[^\\\\]${end}`;
}
export class DelegateJest {
filename: string;
jestMappedResult: Result[] = [];
constructor(filename: string) {
this.filename = filename;
}
jesting(projects: string[], target?: string): ReturnType<typeof runCLI> {
const options = {
_: projects
} as Parameters<typeof runCLI>[0];
if (target) {
options.testNamePattern = target;
}
return runCLI(options, [process.cwd()]);
}
async main(gherkinMap: Step[] = []): Promise<AssertionResult[]> {
const { results } = await this.jesting([this.filename]);
return this.transformResult(results, gherkinMap);
}
transformResult(aggregatedResult: AggregatedResult | null, gherkinMap: Step[] = []): AssertionResult[] {
if (!aggregatedResult) {
return [];
}
const resultInGherkin: AssertionResult[] = [];
aggregatedResult.testResults.forEach((result) =>
result.testResults.forEach((assertionResult) => {
// const stepType = assertionResult.title.match(/^(When|Then|Given)\s(.+)/);
// const relatedSteps: Result[] = [];
// gherkinMap.forEach(step => {
// if (step.host === assertionResult.title) {
// relatedSteps.push(step);
// } else if (stepType && step.key == stepType[1] && step.value === stepType[2]) {
// relatedSteps.push(step);
// }
// });
// if (assertionResult.status === 'passed') {
// this.markStepsStatus(relatedSteps, 'passed');
// } else if (assertionResult.status === 'skipped') {
// this.markStepsStatus(relatedSteps, 'skipped');
// } else if (assertionResult.status === 'failed') {
// // console.log('-----', relatedSteps, assertionResult.title)
// assertionResult.failureMessages.forEach(msg => {
// this.traceErrorFromMessage(msg, relatedSteps);
// });
// } else {
// this.markStepsStatus(relatedSteps, null);
// }
// resultInGherkin.push(...relatedSteps);
resultInGherkin.push(assertionResult);
})
);
return resultInGherkin;
}
markStepsStatus(relatedSteps: Result[], status: Result['status']): void {
relatedSteps.forEach((step) => {
step.status = status;
});
}
traceErrorFromMessage(msg: string, step: Step): Result['status'] {
const lines = msg.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const stackPosition = line.match(/\s+at .*?(\S+):(\d+:\d+)\)*?$/);
if (!stackPosition) {
continue;
} else {
const fileName = stackPosition[1];
if (!fileName.match(this.filename)) {
continue;
}
const [lineNumber, colNumber] = (stackPosition[2]?.split(':')?.map((v) => Number(v) - 1) as
| [number, number]
| null) ?? [0, 0];
if (
step.pos.end.line < lineNumber ||
(step.pos.end.line === lineNumber && step.pos.end.character < colNumber)
) {
return 'passed';
} else if (
step.pos.start.line < lineNumber ||
(step.pos.start.line === lineNumber && step.pos.start.character <= colNumber)
) {
return 'failed';
} else {
return 'passed';
}
}
}
}
sanitizeTitle(
title: string,
params?: ExampleStep['examples']
): {
cucumberStepKey: string;
stepParams?: string[];
} {
const stepParams = title.match(new RegExp(escapableWrapper('<>'), 'g')) as string[] | undefined;
let cucumberStepKey = title.replace(/\(/g, '\\(').replace(')', '\\)');
stepParams?.forEach((p, i) => {
const valKey = p.replace(/<|>/g, '');
stepParams[i] = valKey;
const examplesValue = params?.[0]?.[valKey];
if (examplesValue !== undefined) {
const typeOfValue = typeof examplesValue === 'number' ? '{float}' : '{string}';
cucumberStepKey = cucumberStepKey.replace(p, typeOfValue);
}
});
return { cucumberStepKey, stepParams };
}
matchStepName(
jestStep: StepWithSanTitle,
pickleSteps: { text: string }[],
callback: (values: string[], idx: number) => boolean | void
): void {
const regexText = jestStep.sanitizedTitle
.replace(/\{string\}/g, `(${escapableWrapper('"')}|${escapableWrapper("'")})`)
.replace(/\{float\}/g, `((\-)?\d+(\.\d+)?)`);
const regex = new RegExp(`^${regexText}$`);
for (let i = 0; i < pickleSteps.length; i++) {
const pickleStep = pickleSteps[i];
const matchExp = pickleStep.text.match(regex);
if (matchExp) {
const [_fullText, ...values] = matchExp;
if (callback(values, i)) {
return;
}
}
}
}
dedupeStepName(
dedupe: { text: string; step: StepWithSanTitle }[]
): { text: string; step: StepWithSanTitle }[] {
const paramSteps = [...dedupe.filter((v) => v.step.stepParams)];
paramSteps.forEach(({ step }) => {
this.matchStepName(step, dedupe, (values, idx) => {
dedupe[idx].step.sanitizedTitle = step.sanitizedTitle;
dedupe[idx].step.stepParams = dedupe[idx].step.stepParams ?? new Array(step.stepParams?.length);
});
});
return dedupe.filter(
(v, i) => i === dedupe.findIndex((predicate) => predicate.step.sanitizedTitle === v.step.sanitizedTitle)
);
}
delegateTo(steps: StepWithSanTitle[]): void {
const cachedResult = this.main(steps);
const matchStepName = this.matchStepName;
cucumber.Before<StepContext>(async function (scenario) {
const jestScenario = steps.find(
(step) => step.key === 'Scenario' && step.value === scenario.pickle.name
);
const examplesScenario: Record<string, string | number> = {};
const pickleSteps = [...scenario.pickle.steps];
const relatedSteps = steps.filter((s) => s.parent === scenario.pickle.name);
relatedSteps.forEach((jestStep) => {
if (!jestStep.stepParams) {
return;
}
const jStep = jestStep as StepWithSanTitle & {
stepParams: Exclude<StepWithSanTitle['stepParams'], undefined>;
};
matchStepName(jStep, pickleSteps, (values, idx) => {
const keys = jStep.stepParams;
values.forEach((v, i) => {
// const valueType = typeof jestScenario?.examples?.[0][keys[i]];
// if (valueType === 'string') {
// examplesScenario[keys[i]] = v.replace(/^"|"$/g, '');
// } else if (valueType === 'number') {
// examplesScenario[keys[i]] = Number(v);
// }
if (keys[i]) {
examplesScenario[keys[i]] = JSON.parse(v);
}
});
pickleSteps.splice(idx, 1);
return true;
});
});
const exampleIndex = jestScenario?.examples?.findIndex((v) => {
for (const k in examplesScenario) {
if (v[k] !== examplesScenario[k]) {
return false;
}
}
return true;
});
const result = await cachedResult;
const arrJestMappedResult = result.filter((r) => {
return r.title === scenario.pickle.name;
});
const jestMappedResult = arrJestMappedResult[exampleIndex ?? 0];
this.jest = (this.jest as StepContext['jest'] | null) || {
stepIndex: {},
exampleIndex,
parent: scenario.pickle.name,
jestMappedResult
};
});
const dedupe: { text: string; step: StepWithSanTitle }[] = [];
const scenarios = steps.filter((step) => step.key === 'Scenario');
steps.forEach((step) => {
const keyword = step.key as 'Given' | 'When' | 'Then';
const title = step.value;
if (cucumber[keyword]) {
const parent = scenarios.find((s) => s.key === 'Scenario' && s.value === step.parent);
const { cucumberStepKey, stepParams } = this.sanitizeTitle(title, parent?.examples);
step.sanitizedTitle = cucumberStepKey;
step.stepParams = stepParams;
dedupe.push({ text: cucumberStepKey, step });
}
});
this.dedupeStepName(dedupe).forEach(({ step }) => {
this.registerStepDefinition(step, steps);
});
}
registerStepDefinition(step: StepWithSanTitle, allSteps: StepWithSanTitle[]): void {
const traceErrorFromMessage: DelegateJest['traceErrorFromMessage'] =
this.traceErrorFromMessage.bind(this);
type ICallback = Parameters<typeof fnStepDefinition>[2];
const keyword = step.key as 'Given' | 'When' | 'Then';
const fnStepDefinition: typeof cucumber.defineStep<StepContext> = cucumber[keyword];
const cucumberStepKey = step.sanitizedTitle;
const callback: ICallback = async function (...args) {
// console.log('=====call=====', step.key, cucumberStepKey)
this.jest.stepIndex[cucumberStepKey] = (this.jest.stepIndex[cucumberStepKey] ?? -1) + 1;
const stepInScenario = allSteps.filter((s) => {
return s.parent === this.jest?.parent && s.sanitizedTitle === cucumberStepKey;
})[this.jest.stepIndex[cucumberStepKey]] as StepWithSanTitle | undefined;
if (!stepInScenario) {
// throw new Error(['Not Found', keyword, cucumberStepKey].join('/'))
return 'pending';
}
const { jestMappedResult } = this.jest;
if (!jestMappedResult) {
return 'pending';
} else if (jestMappedResult.status === 'failed') {
if (stepInScenario.host) {
for (let idxMsg = 0; idxMsg < jestMappedResult.failureMessages.length; idxMsg++) {
const status = traceErrorFromMessage(jestMappedResult.failureMessages[idxMsg], stepInScenario);
if (status === 'failed') {
throw errorBuilder(jestMappedResult.failureMessages[idxMsg]);
}
if (status) {
return status;
}
}
return 'pending';
} else {
throw errorBuilder(jestMappedResult.failureMessages[0]);
}
} else {
return jestMappedResult.status;
}
};
if (step.stepParams) {
Object.defineProperty(callback, 'length', {
value: step.stepParams.length
});
}
// console.log('=====regi=====', step.key, cucumberStepKey)
fnStepDefinition(cucumberStepKey, callback);
}
}
function errorBuilder(msg: string): Error {
const matched = msg.match(/Error: ([^\n]+)\n(.+)/);
const err = new Error();
if (!matched) {
err.message = msg;
} else {
err.message = matched[1];
err.stack = matched[2];
}
err.message = err.message.replace(/\[\d+m/g, '');
return err;
}
export default DelegateJest;