nx/packages/jest/src/utils/config/functions.ts
2023-02-22 18:47:40 -05:00

308 lines
8.5 KiB
TypeScript

import type * as ts from 'typescript';
import { applyChangesToString, ChangeType, Tree } from '@nrwl/devkit';
import { Config } from '@jest/types';
import { createContext, runInContext } from 'vm';
import { dirname, join } from 'path';
import { ensureTypescript } from '@nrwl/js/src/utils/typescript/ensure-typescript';
let tsModule: typeof import('typescript');
function makeTextToInsert(
value: unknown,
precedingCommaNeeded: boolean
): string {
return `${precedingCommaNeeded ? ',' : ''}${value}`;
}
function findPropertyAssignment(
object: ts.ObjectLiteralExpression,
propertyName: string
) {
if (!tsModule) {
tsModule = ensureTypescript();
}
return object.properties.find((prop) => {
if (!tsModule.isPropertyAssignment(prop)) {
return false;
}
const propNameText = prop.name.getText();
if (propNameText.match(/^["'].+["']$/g)) {
return JSON.parse(propNameText.replace(/'/g, '"')) === propertyName;
}
return propNameText === propertyName;
}) as ts.PropertyAssignment | undefined;
}
export function addOrUpdateProperty(
tree: Tree,
object: ts.ObjectLiteralExpression,
properties: string[],
value: unknown,
path: string
) {
if (!tsModule) {
tsModule = ensureTypescript();
}
const { SyntaxKind } = tsModule;
const propertyName = properties.shift();
const propertyAssignment = findPropertyAssignment(object, propertyName);
const originalContents = tree.read(path, 'utf-8');
if (propertyAssignment) {
if (
propertyAssignment.initializer.kind === SyntaxKind.StringLiteral ||
propertyAssignment.initializer.kind === SyntaxKind.NumericLiteral ||
propertyAssignment.initializer.kind === SyntaxKind.FalseKeyword ||
propertyAssignment.initializer.kind === SyntaxKind.TrueKeyword
) {
const updatedContents = applyChangesToString(originalContents, [
{
type: ChangeType.Delete,
start: propertyAssignment.initializer.pos,
length: propertyAssignment.initializer.getFullText().length,
},
{
type: ChangeType.Insert,
index: propertyAssignment.initializer.pos,
text: value as string,
},
]);
tree.write(path, updatedContents);
return;
}
if (
propertyAssignment.initializer.kind === SyntaxKind.ArrayLiteralExpression
) {
const arrayLiteral =
propertyAssignment.initializer as ts.ArrayLiteralExpression;
if (
arrayLiteral.elements.some((element) => {
return element.getText().replace(/'/g, '"') === value;
})
) {
return [];
}
if (arrayLiteral.elements.length === 0) {
const updatedContents = applyChangesToString(originalContents, [
{
type: ChangeType.Insert,
index: arrayLiteral.elements.end,
text: value as string,
},
]);
tree.write(path, updatedContents);
return;
} else {
const text = makeTextToInsert(
value,
arrayLiteral.elements.length !== 0 &&
!arrayLiteral.elements.hasTrailingComma
);
const updatedContents = applyChangesToString(originalContents, [
{
type: ChangeType.Insert,
index: arrayLiteral.elements.end,
text,
},
]);
tree.write(path, updatedContents);
return;
}
} else if (
propertyAssignment.initializer.kind === SyntaxKind.ObjectLiteralExpression
) {
return addOrUpdateProperty(
tree,
propertyAssignment.initializer as ts.ObjectLiteralExpression,
properties,
value,
path
);
}
} else {
if (propertyName === undefined) {
throw new Error(
`Please use dot delimited paths to update an existing object. Eg. object.property `
);
}
const text = makeTextToInsert(
`${JSON.stringify(propertyName)}: ${value}`,
object.properties.length !== 0 && !object.properties.hasTrailingComma
);
const updatedContents = applyChangesToString(originalContents, [
{
type: ChangeType.Insert,
index: object.properties.end,
text,
},
]);
tree.write(path, updatedContents);
return;
}
}
export function removeProperty(
object: ts.ObjectLiteralExpression,
properties: string[]
): ts.PropertyAssignment | null {
if (!tsModule) {
tsModule = ensureTypescript();
}
const propertyName = properties.shift();
const propertyAssignment = findPropertyAssignment(object, propertyName);
if (propertyAssignment) {
if (
properties.length > 0 &&
propertyAssignment.initializer.kind ===
tsModule.SyntaxKind.ObjectLiteralExpression
) {
return removeProperty(
propertyAssignment.initializer as ts.ObjectLiteralExpression,
properties
);
}
return propertyAssignment;
} else {
return null;
}
}
function isModuleExport(node: ts.Statement) {
if (!tsModule) {
tsModule = ensureTypescript();
}
return (
tsModule.isExpressionStatement(node) &&
node.expression?.kind &&
tsModule.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
node.expression.operatorToken?.kind === tsModule.SyntaxKind.EqualsToken
);
}
function isDefaultExport(node: ts.Statement) {
if (!tsModule) {
tsModule = ensureTypescript();
}
return (
tsModule.isExportAssignment(node) &&
node.expression?.kind &&
tsModule.isObjectLiteralExpression(node.expression) &&
node.getText().startsWith('export default')
);
}
/**
* Should be used to get the jest config object as AST
*/
export function jestConfigObjectAst(
fileContent: string
): ts.ObjectLiteralExpression {
if (!tsModule) {
tsModule = ensureTypescript();
}
const sourceFile = tsModule.createSourceFile(
'jest.config.ts',
fileContent,
tsModule.ScriptTarget.Latest,
true
);
const exportStatement = sourceFile.statements.find(
(statement) => isModuleExport(statement) || isDefaultExport(statement)
);
let ast: ts.ObjectLiteralExpression;
if (tsModule.isExpressionStatement(exportStatement)) {
const moduleExports = exportStatement.expression as ts.BinaryExpression;
if (!moduleExports) {
throw new Error(
`
The provided jest config file does not have the expected 'module.exports' expression.
See https://jestjs.io/docs/en/configuration for more details.`
);
}
ast = moduleExports.right as ts.ObjectLiteralExpression;
} else if (tsModule.isExportAssignment(exportStatement)) {
const defaultExport =
exportStatement.expression as ts.ObjectLiteralExpression;
if (!defaultExport) {
throw new Error(
`
The provided jest config file does not have the expected 'export default' expression.
See https://jestjs.io/docs/en/configuration for more details.`
);
}
ast = defaultExport;
}
if (!ast) {
throw new Error(
`
The provided jest config file does not have the expected 'module.exports' or 'export default' expression.
See https://jestjs.io/docs/en/configuration for more details.`
);
}
if (!tsModule.isObjectLiteralExpression(ast)) {
throw new Error(
`The 'export default' or 'module.exports' expression is not an object literal.`
);
}
return ast;
}
/**
* Returns the jest config object
* @param host
* @param path
*/
export function jestConfigObject(
host: Tree,
path: string
): Partial<Config.InitialOptions> & { [index: string]: any } {
const __filename = join(host.root, path);
const contents = host.read(path, 'utf-8');
let module = { exports: {} };
// transform the export default syntax to module.exports
// this will work for the default config, but will break if there are any other ts syntax
// TODO(caleb): use the AST to transform back to the module.exports syntax so this will keep working
// or deprecate and make a new method for getting the jest config object
const forcedModuleSyntax = contents.replace(
/export\s+default/,
'module.exports ='
);
// Run the contents of the file with some stuff from this current context
// The module.exports will be mutated by the contents of the file...
runInContext(
forcedModuleSyntax,
createContext({
module,
require,
process,
__filename,
__dirname: dirname(__filename),
})
);
// TODO: jest actually allows defining configs with async functions... we should be able to read that...
return module.exports;
}