jest-bdd-generator
Version:
Jest code generator empowered with Gherkin and BDD
589 lines (551 loc) • 19.7 kB
text/typescript
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @ttfe/@typescript-eslint/no-unnecessary-condition */
import * as ts from 'typescript';
import { IStepKey, Step } from '../types';
type ISearchableExpression =
| ts.Identifier
| ts.PropertyAccessExpression
| ts.ParenthesizedExpression
| ts.CallExpression
| ts.AwaitExpression
| ts.TaggedTemplateExpression;
// function isTsNode(node: Exclude<unknown, ts.Node>): false;
// function isTsNode(node: ts.Node): true;
function isTsNode(node: unknown): boolean {
const v = node as ts.Node | undefined;
return !!v && !!(ts.SyntaxKind[v.kind] && ts.NodeFlags[v.flags]);
}
const compareNodesIgnores = ['pos', 'end', 'id', 'parent', 'nextContainer'];
function compareNodes(node1: ts.Node, node2: ts.Node): boolean {
const keys = Object.keys(node1) as Array<keyof ts.Node>;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (compareNodesIgnores.indexOf(k) !== -1) {
continue;
}
const v1 = node1[k];
const v2 = node2[k];
if (typeof v1 === 'string') {
if (typeof v2 !== 'string' || v2 !== v1) {
return false;
}
} else if (typeof v1 === 'number') {
if (typeof v2 !== 'number' || v2 !== v1) {
return false;
}
} else if (Array.isArray(v1)) {
if (!Array.isArray(v2) || v2.length !== v1.length) {
return false;
}
for (let j = 0; j < v1.length; j++) {
if (!compareNodes(v1[j], v2[j])) {
return false;
}
}
} else if (isTsNode(v1)) {
if (!isTsNode(v2)) {
return false;
}
// console.log(k);
if (!compareNodes(v1 as ts.Node, v2 as ts.Node)) {
return false;
}
}
}
return true;
}
function searchCallName(node: ISearchableExpression): string | string[] | void {
if (ts.isParenthesizedExpression(node) && ts.isBinaryExpression(node.expression)) {
return searchCallName(node.expression.right as ts.Identifier | ts.ParenthesizedExpression);
} else if (ts.isPropertyAccessExpression(node)) {
// property access node, such as 'a.b' : expression == 'a'; /* left */; name == 'b' /* right */;
return searchCallName(node.expression as Parameters<typeof searchCallName>[0]) ?? node.name.escapedText;
} else if (ts.isIdentifier(node)) {
return node.escapedText;
} else if (ts.isCallExpression(node) || ts.isAwaitExpression(node)) {
return searchCallName(node.expression as Parameters<typeof searchCallName>[0]);
} else if (ts.isTaggedTemplateExpression(node)) {
return searchCallName(node.tag as Parameters<typeof searchCallName>[0]);
}
}
function traverseArguments(
arrArgsMap: ISearchCallSchema[0],
callExpression: ts.CallExpression,
context: ts.TransformationContext
): void {
const contextCallExpression = {} as IArgumentsSearchContext;
for (let argIndex = 0; argIndex < arrArgsMap.arguments.length; argIndex++) {
const argMapTo = arrArgsMap.arguments[argIndex];
const currentArgument = callExpression.arguments[argIndex];
if (argMapTo.callback) {
argMapTo.callback(currentArgument, callExpression, contextCallExpression);
}
if (argMapTo.searchCallSchema) {
if (ts.isArrowFunction(currentArgument) || ts.isFunctionExpression(currentArgument)) {
traverseChildren(argMapTo.searchCallSchema, currentArgument.body, context);
}
}
}
}
function traverseChildren(
signatures: ISearchCallSchema,
node: ts.Node,
context: ts.TransformationContext
): void {
ts.visitEachChild(
node,
(subNode) => {
if (ts.isExpressionStatement(subNode) && ts.isCallExpression(subNode.expression)) {
const callExpression = subNode.expression;
const name = searchCallName(callExpression.expression as ISearchableExpression);
for (let i = 0; i < signatures.length; i++) {
const arrArgsMap = signatures[i];
if (typeof arrArgsMap === 'function') {
continue;
}
if (name === arrArgsMap.keyword) {
// there is a match
traverseArguments(arrArgsMap, callExpression, context);
if (arrArgsMap.chain) {
let subExpression: ts.CallExpression | ts.PropertyAccessExpression = callExpression;
while (subExpression.expression) {
if (ts.isPropertyAccessExpression(subExpression.expression)) {
const nameChain = subExpression.expression.name.escapedText;
const chainSchema = arrArgsMap.chain.find((schemaKey) => schemaKey.keyword === nameChain);
if (chainSchema) {
traverseArguments(chainSchema, subExpression, context);
}
return subNode;
}
subExpression = subExpression.expression as ts.CallExpression;
}
}
}
}
}
},
context
);
}
export type ICallbackOnFunctionExpression = (
node: ts.Expression,
callExpression: ts.CallExpression,
context: IArgumentsSearchContext,
..._any: unknown[]
) => void;
export type IArgumentsSearchContext = {
host: string;
stepOfCurrentFunction: Step;
};
export type ISearchExpressionSchema = {
keyword: string;
arguments: { callback?: ICallbackOnFunctionExpression; searchCallSchema?: ISearchCallSchema }[];
chain?: ISearchCallSchema;
};
export type ISearchCallSchema = Array<ISearchExpressionSchema>;
export const customTransformerFactory: (
searchSchema: ISearchCallSchema,
self: Transpile
) => ts.CustomTransformerFactory = (searchSchema, self) => (context) => {
return {
transformBundle: (node) => node,
transformSourceFile: (node: ts.SourceFile) => {
self.sourceFile = node;
if (node.fileName.match(/\.test\.ts/)) {
traverseChildren(searchSchema, node, context);
}
return node;
}
};
};
export class Transpile {
searchSchema!: ISearchCallSchema;
source: string = '';
sourceFile?: ts.SourceFile;
public output: Step[] = [];
public get steps(): Step[] {
return this.output.map((s) => {
const { key, value, host, parent, pos, examples } = s;
return { key, value, host, parent, pos, examples };
});
}
public get uniqueSteps(): Step[] {
const stepsCopied = this.steps;
return stepsCopied.filter(
(s, i) => stepsCopied.findIndex((f) => f.key === s.key && f.value === s.value) === i
);
}
// public get outputText(): string {
// return this._prepareOutput();
// }
public possibleStep: Step[] = [];
public set input(input: string) {
const sourceMap = this.dealwithSourceMap(input);
if (sourceMap?.sourcesContent?.[0]) {
this.source = sourceMap.sourcesContent[0];
} else {
this.source = input;
}
}
get input(): string {
return this.source;
}
constructor(searchSchema?: ISearchCallSchema) {
if (searchSchema) {
this.searchSchema = searchSchema;
}
}
parsePosition(pos: number): Step['pos']['start'] {
if (this.sourceFile) {
const { line, character } = this.sourceFile.getLineAndCharacterOfPosition(pos);
return {
line,
character,
pos
};
} else {
throw new Error();
}
}
groupToScenario(): void {
const scenarios = this.output.filter((step) => step.key === 'Scenario');
this.output.forEach((step) => {
if (step.key === 'Scenario') {
return;
}
switch (step.key) {
case 'Given':
case 'When':
case 'Then': {
const parent = scenarios.find(
(s) => s.pos.start.pos < step.pos.start.pos && s.pos.end.pos >= step.pos.end.pos //s.pos.start.line < step.pos.start.line && s.pos.end.line > step.pos.end.line,
);
if (!parent) {
return;
}
step.parent = parent.value;
break;
}
case 'Examples': {
const parent = scenarios.find((s) => s.value === step.value && s.key === 'Scenario');
if (!parent) {
return;
}
step.parent = parent.value;
parent.examples = step.examples;
break;
}
default:
break;
}
});
}
getDeclaration(declaration: ts.VariableDeclaration | ts.BindingElement): string[] {
if (ts.isObjectBindingPattern(declaration.name)) {
return declaration.name.elements.map((a) => this.getDeclaration(a)).flat();
} else if (ts.isIdentifier(declaration.name)) {
return [declaration.name.escapedText.toString()];
}
return [];
}
generateComments(): string {
const possibleStep: Step[] = this.possibleStep ?? [];
const output: string[] = [];
let lastIndex = 0;
possibleStep.reduce((prev, step, i, self) => {
const start = step.pos.start.pos;
if (step.key === ('Matched Scenario' as any as IStepKey)) {
// output.push(`//@Scenario ${step.value}`)
return prev;
}
if (start < lastIndex) {
console.error('warning: Overlap!');
return prev;
}
const end = step.pos.end.pos;
const stepDuplicated = prev.find((s) => s.key === step.key && s.value === step.value);
if (stepDuplicated) {
return prev;
}
output.push(
this.input.substring(lastIndex, start),
`\n`,
...step.value.split('\n').map((v) => `\n //@${step.key}${v}`),
this.input.substring(start, end),
`\n //# end of matched [@${step.value}] from [${step.parent}] Scenario\n`
);
lastIndex = end;
return prev;
}, [] as Step[]);
output.push(this.input.substring(lastIndex));
return output.join('');
}
generateFromKnownSteps(block: ts.Block): Transpile['output'] {
const mapStatementsMatch = this.output.map((step) => ({
key: step.key,
value: step.value,
idxStatement: 0
}));
const matched = [] as Array<{
key: string;
value: string;
startIdx: number;
endIdx: number;
from: Step[];
}>;
// const ret: Transpile['output'] = [];
block.statements.forEach((statement, idxCurrentStatement) => {
mapStatementsMatch.forEach((map, idx) => {
if (map.idxStatement < 0) {
return;
}
const step = this.output[idx];
const statementToCompare = step?.sourceCode?.statements?.[map.idxStatement];
if (!statementToCompare) {
return;
}
const isCurrentStatMatch = compareNodes(statement, statementToCompare);
if (isCurrentStatMatch) {
map.idxStatement++;
if (map.idxStatement === step.sourceCode?.statements?.length) {
const fullyMatched = {
key: map.key,
value: `${map.key} ${map.value}`,
startIdx: idxCurrentStatement - map.idxStatement + 1,
endIdx: idxCurrentStatement,
from: [step]
};
const duplication = matched.find(
(m) => m.startIdx === fullyMatched.startIdx && m.endIdx === fullyMatched.endIdx
);
if (duplication) {
if (duplication.key === fullyMatched.key && duplication.value === fullyMatched.value) {
duplication.from.push(step);
} else {
duplication.value += `\n${fullyMatched.value}`;
}
} else {
matched.push(fullyMatched);
}
map.idxStatement = 0; // start over
}
} else {
map.idxStatement = 0;
}
});
});
return matched.reduce(
(dedupe, match, idx, self) => {
const firstStatement = block.statements[match.startIdx];
const lastStatement = block.statements[match.endIdx];
const statements = block.statements.slice(match.startIdx, match.endIdx + 1);
dedupe.push({
key: `` as any as IStepKey,
value: match.value,
pos: {
start: this.parsePosition(firstStatement.pos),
end: this.parsePosition(lastStatement.end)
},
parent: match.from.map((step) => step.host).join(', '),
sourceCode: {
statements,
fullText: statements.map((s) => s.getFullText()).join(''),
imports: [...(match.from[0].sourceCode?.imports ?? [])],
exports: [...(match.from[0].sourceCode?.exports ?? [])]
}
});
return dedupe;
},
[] as Transpile['output']
);
}
getComments(block: ts.Block, _lastItem?: Step): Transpile['output'] {
const ret: Transpile['output'] = [];
let lastItem = _lastItem;
block.statements.forEach((fnHandler) => {
const commentRange = ts.getLeadingCommentRanges(this.input, fnHandler.getFullStart());
commentRange?.forEach((range) => {
const strComment = this.input.substring(range.pos, range.end);
const keywords = strComment?.match(/^\s*?\/\/\s*?@(When|Then|Given) (.+[^\s])\s*?$/);
if (!keywords) {
return strComment;
}
ret.push({
key: keywords[1] as IStepKey,
value: keywords[2],
pos: {
start: this.parsePosition(range.pos),
end: this.parsePosition(fnHandler.end)
},
sourceCode: {
fullText: '',
imports: [],
exports: [],
statements: []
}
});
return strComment;
});
lastItem = ret[ret.length - 1] ?? lastItem;
if (lastItem) {
lastItem.sourceCode = lastItem.sourceCode || { fullText: '', statements: [] };
lastItem.sourceCode.statements?.push(fnHandler);
if (ts.isExpressionStatement(fnHandler)) {
const arrInputs = [
searchCallName(fnHandler.expression as ISearchableExpression)
].flat() as string[];
lastItem.sourceCode.imports = lastItem.sourceCode?.imports || [];
lastItem.sourceCode.imports.push(
...arrInputs.filter((i) => !lastItem?.sourceCode?.imports?.includes(i))
);
} else if (ts.isVariableStatement(fnHandler)) {
const arrOutputs = fnHandler.declarationList.declarations.map((declaration) =>
this.getDeclaration(declaration)
);
lastItem.sourceCode.exports = lastItem.sourceCode.exports || [];
lastItem.sourceCode.exports.push(
...arrOutputs.flat().filter((i) => !lastItem?.sourceCode?.exports?.includes(i))
);
}
}
if (ts.isIfStatement(fnHandler) && ts.isBlock(fnHandler.thenStatement)) {
ret.push(...this.getComments(fnHandler.thenStatement, lastItem));
}
});
return ret.flat();
}
/**
*
* @param name Gherkin Keyword, Feature or Scenario
* @returns the retrieved step
*/
callbackOnStringArgumentFactory: (name: IStepKey) => ICallbackOnFunctionExpression = (name) => {
return (expressionOfArg, callExpression, context) => {
let argValue: string;
if (ts.isStringLiteral(expressionOfArg) || ts.isNoSubstitutionTemplateLiteral(expressionOfArg)) {
argValue = expressionOfArg.text;
} else if (ts.isTemplateExpression(expressionOfArg)) {
argValue =
expressionOfArg.head.text +
expressionOfArg.templateSpans
.map((sp) => `<${sp.expression.getText()}>${sp.literal.text}`)
.join('');
} else {
return;
}
let k: IStepKey, v: string;
const matchedValue = argValue.match(/^(When|Then|Given) (.+)$/);
if (matchedValue) {
k = matchedValue[1] as IStepKey;
v = matchedValue[2];
} else {
k = name;
v = argValue;
}
const fnArgument = callExpression.arguments.find((n) => ts.isFunctionLike(n) && ts.isBlock(n.body)) as
| ts.FunctionExpression
| ts.ArrowFunction;
const fnArgumentBody = fnArgument?.body as ts.BlockLike;
const step: Step = {
key: k,
value: v,
pos: {
start: this.parsePosition(expressionOfArg.pos),
end: this.parsePosition(expressionOfArg.end)
},
sourceCode: {
fullText: callExpression.getFullText(),
// ["describe", "(", ["some title", ",", "() => {....}"], ")"]
statements: [...(fnArgumentBody?.statements ?? [])]
},
host: argValue
};
this.output.push(step);
context.stepOfCurrentFunction = step;
context.host = argValue;
};
};
callbackOnFnArgument: ICallbackOnFunctionExpression = (expressionOfArg, callExpression, callContext) => {
const steps: Transpile['output'] = [];
const host = callContext.host as string;
const stepOfCurrentFunction = callContext.stepOfCurrentFunction as Step;
const param = expressionOfArg as ts.FunctionExpression;
if (!param.body) {
console.error('Argument type is not functionExpression', ts.SyntaxKind[param.kind]);
return;
}
stepOfCurrentFunction.pos.start = this.parsePosition(param.body.pos);
stepOfCurrentFunction.pos.end = this.parsePosition(param.body.end);
steps.push(...this.getComments(param.body, stepOfCurrentFunction));
if (steps.length > 0) {
// const stepHost = steps[0];
steps[steps.length - 1].pos.end = { ...stepOfCurrentFunction.pos.end };
if (stepOfCurrentFunction.key !== 'Scenario') {
stepOfCurrentFunction.pos.end = this.parsePosition(steps[0].pos.start.pos - 1);
}
steps[0].host = host;
for (let i = 1; i < steps.length; i++) {
steps[i].host = host;
const startCurrentStep = steps[i].pos.start;
const posCurrentStart = startCurrentStep.pos - 1;
steps[i - 1].pos.end = this.parsePosition(posCurrentStart);
}
} else {
this.possibleStep.push(...this.generateFromKnownSteps(param.body));
}
this.output.push(...steps);
};
dealwithSourceMap(input: string): Record<string, string> {
// const sourceMapData = input.split('//# sourceMappingURL=data:application/json;charset=utf-8;base64,')[1];
// if (!sourceMapData || !Buffer) {
return {} as never;
// }
// const buff = Buffer.from(sourceMapData, 'base64');
// const rawSourceMap = buff.toString('utf-8');
// //console.log(rawSourceMap)
// return JSON.parse(rawSourceMap);
}
transpile(input: string, options: ts.TranspileOptions = {}): ts.TranspileOutput {
if (options.fileName?.match(/\.test\.ts$/)) {
this.input = input;
const raw = ts.transpileModule(this.input, {
...options,
transformers: {
before: [customTransformerFactory(this.searchSchema, this)]
},
compilerOptions: {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.React,
isolatedModules: true,
sourceMap: true
}
});
const output: ts.TranspileOutput = {
outputText: this._prepareOutput(),
sourceMapText: raw.sourceMapText
};
return output;
} else {
return ts.transpileModule(input, {
...options,
compilerOptions: {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.React,
isolatedModules: true,
sourceMap: true
}
});
}
}
private _prepareOutput(): string {
this.groupToScenario();
// console.log(new Array(10).fill('+').join('\n'));
// console.log(this.output);
return this.outputCode();
}
outputCode(): string {
return '';
}
}