Skip to content

Instantly share code, notes, and snippets.

@barbados-clemens
Last active July 14, 2022 13:39
Show Gist options
  • Save barbados-clemens/e517c6c8507622c6f8e663a00046f2c7 to your computer and use it in GitHub Desktop.
Save barbados-clemens/e517c6c8507622c6f8e663a00046f2c7 to your computer and use it in GitHub Desktop.
Work in making a ts transformer for updating the cypress.config.ts file for nrwl. Ended up not using but wanted to keep around as a ref (Thanks for the help building this Chau!)
import { CypressConfigTransformer } from './change-config-transformer';
describe('Update Cypress Config', () => {
const defaultConfigContent = `
import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing';
import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset';
export default defineConfig({
component: nxComponentTestingPreset(__dirname),
e2e: nxE2EPreset(__dirname),
})`;
const configWithSpread = `
import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing';
import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset';
export default defineConfig({
component: {
...nxComponentTestingPreset(__dirname),
video: false,
screenshotsFolder: '../blah/another/value'
}
e2e: {
...nxE2EPreset(__dirname),
video: false,
screenshotsFolder: '../blah/another/value'
}
})`;
const expandedConfigContent = `
import { defineConfig } from 'cypress';
import { componentDevServer } from '@nrwl/cypress/plugins/next';
export default defineConfig({
baseUrl: 'blah its me',
component: {
devServer: componentDevServer('tsconfig.cy.json', 'babel'),
video: true,
chromeWebSecurity: false,
fixturesFolder: 'cypress/fixtures',
specPattern: '**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/component.ts',
videosFolder: '../../dist/cypress/apps/n/videos',
screenshotsFolder: '../../dist/cypress/apps/n/screenshots',
},
e2e: {
fileServerFolder: '.',
fixturesFolder: './src/fixtures',
integrationFolder: './src/e2e',
supportFile: './src/support/e2e.ts',
specPattern: '**/*.cy.{js,ts}',
video: true,
videosFolder: '../../dist/cypress/apps/myapp4299814-e2e/videos',
screenshotsFolder: '../../dist/cypress/apps/myapp4299814-e2e/screenshots',
chromeWebSecurity: false,
}
});
`;
it('should add and update existing properties', () => {
const actual = CypressConfigTransformer.addOrUpdateProperties(
expandedConfigContent,
{
blah: 'i am a top level property',
baseUrl: 'http://localhost:1234',
component: {
fixturesFolder: 'cypress/fixtures/cool',
devServer: { tsConfig: 'tsconfig.spec.json', compiler: 'swc' },
// @ts-ignore
blah: 'i am a random property',
},
e2e: {
video: false,
},
}
);
expect(actual).toMatchSnapshot();
});
it('should overwrite existing config', () => {
const actual = CypressConfigTransformer.addOrUpdateProperties(
expandedConfigContent,
{
baseUrl: 'http://overwrite:8080',
component: {
devServer: { tsConfig: 'tsconfig.spec.json', compiler: 'swc' },
},
e2e: {
video: false,
},
},
true
);
expect(actual).toMatchSnapshot();
});
it('should remove properties', () => {
const actual = CypressConfigTransformer.removeProperties(
expandedConfigContent,
[
'baseUrl',
'component.devServer',
'component.specPattern',
'component.video',
'e2e.chromeWebSecurity',
'e2e.screenshotsFolder',
'e2e.video',
]
);
expect(actual).toMatchSnapshot();
});
it('should add property to default config', () => {
const actual = CypressConfigTransformer.addOrUpdateProperties(
defaultConfigContent,
{
e2e: {
baseUrl: 'http://localhost:1234',
},
component: {
video: false,
},
}
);
expect(actual).toMatchSnapshot();
});
it('should add property with spread config', () => {
const actual = CypressConfigTransformer.addOrUpdateProperties(
configWithSpread,
{
e2e: {
baseUrl: 'http://localhost:1234',
},
component: {
defaultCommandTimeout: 60000,
},
}
);
expect(actual).toMatchSnapshot();
});
it('should delete a property with spread config', () => {
const actual = CypressConfigTransformer.removeProperties(configWithSpread, [
'component.defaultCommandTimeout',
'component.screenshotsFolder',
'e2e.baseUrl',
'e2e.video',
]);
expect(actual).toMatchSnapshot();
});
it('should not change the default config with removal', () => {
// default config is a direct assignment vs object expression so there is nothing to remove.
const actual = CypressConfigTransformer.removeProperties(
defaultConfigContent,
[
'component.screenshotsFolder',
'component.video',
'e2e.screenshotsFolder',
'e2e.video',
]
);
expect(actual).toMatchSnapshot();
});
});
import {
CallExpression,
createPrinter,
createSourceFile,
Expression,
Identifier,
isCallExpression,
isExportAssignment,
isNumericLiteral,
isObjectLiteralExpression,
isPropertyAssignment,
isSpreadAssignment,
Node,
NodeFactory,
ObjectLiteralElementLike,
ObjectLiteralExpression,
ScriptTarget,
SourceFile,
StringLiteral,
SyntaxKind,
transform,
TransformationContext,
TransformerFactory,
visitEachChild,
visitNode,
Visitor,
} from 'typescript';
import {
CypressConfig,
CypressConfigPropertyPath,
isBooleanLiteral,
Overwrite,
} from './transformer.helper';
export type ModifiedCypressConfig = Overwrite<
CypressConfig,
{
component?: Overwrite<
CypressConfig['component'],
{ devServer?: { tsConfig: string; compiler: string } }
>;
}
>;
type PrimitiveValue = string | number | boolean;
type DevServer = {
[key in 'tsConfig' | 'compiler']?: PrimitiveValue;
};
type UpsertArgs = {
type: 'upsert';
newConfig: ModifiedCypressConfig;
overwrite?: boolean;
};
type DeleteArgs = { type: 'delete' };
const devServerPositionalArgument = ['tsConfig', 'compiler'] as const;
/**
* Update cypress.config.ts file properties.
* Does not support cypress.config.json. use updateJson from @nrwl/devkit
*/
export class CypressConfigTransformer {
private static configMetadataMap = new Map<
string,
PrimitiveValue | Map<string, PrimitiveValue | DevServer>
>();
private static propertiesToRemove: CypressConfigPropertyPath[] = [];
private static sourceFile: SourceFile;
static removeProperties(
existingConfigContent: string,
toRemove: CypressConfigPropertyPath[]
): string {
this.configMetadataMap = new Map();
this.propertiesToRemove = toRemove;
this.sourceFile = createSourceFile(
'cypress.config.ts',
existingConfigContent,
ScriptTarget.Latest,
true
);
const transformedResult = transform(this.sourceFile, [
this.changePropertiesTransformer({ type: 'delete' }),
]);
return createPrinter().printFile(transformedResult.transformed[0]);
}
static addOrUpdateProperties(
existingConfigContent: string,
newConfig: ModifiedCypressConfig,
overwrite = false
): string {
this.configMetadataMap = new Map();
this.propertiesToRemove = [];
this.sourceFile = createSourceFile(
'cypress.config.ts',
existingConfigContent,
ScriptTarget.Latest,
true
);
const transformedResult = transform(this.sourceFile, [
this.changePropertiesTransformer({
type: 'upsert',
newConfig,
overwrite,
}),
]);
return createPrinter().printFile(transformedResult.transformed[0]);
}
private static changePropertiesTransformer(
change: UpsertArgs | DeleteArgs
): TransformerFactory<SourceFile> {
return (context: TransformationContext) => {
if (change.type === 'upsert') {
// before visiting the sourceFile (aka the existing config)
// we add the newConfig, as TypeScript AST, to our ConfigMetadata
const newConfigAst: ObjectLiteralExpression =
context.factory.createObjectLiteralExpression(
Object.entries(change.newConfig).map(([configKey, configValue]) =>
createObjectAssignments(context.factory, configKey, configValue)
),
true
);
this.buildMetadataFromConfig(context.factory, newConfigAst);
}
return (sourceFile: SourceFile) => {
const nodeVisitor: Visitor = (node: Node): Node => {
if (isExportAssignment(node)) {
const callExpression = node.expression as CallExpression;
const rootConfigNode = callExpression
.arguments[0] as ObjectLiteralExpression;
if (
change.type === 'delete' ||
(change.type === 'upsert' && !change.overwrite)
) {
this.buildMetadataFromConfig(context.factory, rootConfigNode);
}
return context.factory.updateExportAssignment(
node,
node.decorators,
node.modifiers,
context.factory.updateCallExpression(
callExpression,
callExpression.expression,
callExpression.typeArguments,
[
context.factory.createObjectLiteralExpression(
[...this.configMetadataMap.entries()]
.map(([configKey, configValue]) => {
return createObjectAssignments(
context.factory,
configKey,
configValue
);
})
.sort((propA, propB) =>
isSpreadAssignment(propA) ? -1 : 1
),
true
),
]
)
);
}
return visitEachChild(node, nodeVisitor, context);
};
return visitNode(sourceFile, nodeVisitor);
};
};
}
private static buildMetadataFromConfig(
factory: NodeFactory,
config: ObjectLiteralExpression,
metadataMap = this.configMetadataMap,
propertiesToRemove: CypressConfigPropertyPath[] = this.propertiesToRemove,
parentPrefix = ''
): void {
if (!Array.isArray(config.properties)) {
console.log(config);
return;
}
for (const property of config.properties) {
let assignment = property;
if (isPropertyAssignment(property)) {
const assignmentName = (assignment.name as Identifier).text;
const propertyPath = parentPrefix
? parentPrefix.concat('.', assignmentName)
: assignmentName;
if (
propertiesToRemove.includes(propertyPath as CypressConfigPropertyPath)
) {
continue;
}
if (assignmentName === 'devServer') {
if (isCallExpression(assignment.initializer)) {
assignment = factory.updatePropertyAssignment(
assignment,
assignment.name,
factory.createObjectLiteralExpression(
assignment.initializer.arguments.map((arg, index) => {
return factory.createPropertyAssignment(
factory.createIdentifier(
devServerPositionalArgument[index]
),
arg
);
}),
true
)
);
}
}
const existingMetadata = metadataMap.get(assignmentName);
if (existingMetadata !== undefined) {
if (existingMetadata instanceof Map) {
if (isCallExpression(assignment.initializer)) {
existingMetadata.set(
assignment.initializer.expression.getFullText(),
[
// we are in an existing object so now we need to spread the existing object
// i.e. { blah: somFn(args)} => { blah: {...someFn(args), anotherProp} }
SyntaxKind.SpreadAssignment,
assignment.initializer.arguments,
] as any
);
} else {
this.buildMetadataFromConfig(
factory,
assignment.initializer as ObjectLiteralExpression,
existingMetadata as any,
propertiesToRemove,
propertyPath
);
}
}
continue;
}
if (isObjectLiteralExpression(assignment.initializer)) {
const childMetadataMap = new Map();
metadataMap.set(assignmentName, childMetadataMap);
this.buildMetadataFromConfig(
factory,
assignment.initializer,
childMetadataMap,
propertiesToRemove,
propertyPath
);
} else if (isCallExpression(assignment.initializer)) {
metadataMap.set(assignmentName, [
assignment.initializer.kind,
assignment.initializer.expression.getFullText(),
assignment.initializer.arguments,
] as any);
} else {
metadataMap.set(
assignmentName,
fromLiteralToPrimitive(assignment.initializer)
);
}
} else if (isSpreadAssignment(property)) {
if (isCallExpression(property.expression)) {
const callExpression = property.expression;
metadataMap.set(callExpression.expression.getFullText(), [
SyntaxKind.SpreadAssignment,
callExpression.arguments,
] as any);
}
}
}
}
}
function fromLiteralToPrimitive(nodeInitializer: Expression): PrimitiveValue {
if (isNumericLiteral(nodeInitializer)) {
return Number(nodeInitializer.text);
}
if (isBooleanLiteral(nodeInitializer)) {
if (nodeInitializer.kind === SyntaxKind.TrueKeyword) {
return true;
}
if (nodeInitializer.kind === SyntaxKind.FalseKeyword) {
return false;
}
}
return (nodeInitializer as StringLiteral).text;
}
function createObjectAssignments(
factory: NodeFactory,
key: string,
value:
| unknown
| [type: SyntaxKind, args: Expression[]]
| [type: SyntaxKind, identifier: string, args: Expression[]]
): ObjectLiteralElementLike {
if (key === 'devServer' && value instanceof Map) {
return factory.createPropertyAssignment(
factory.createIdentifier('devServer'),
factory.createCallExpression(
factory.createIdentifier('componentDevServer'),
undefined,
// TODO(caleb): parse args into correct types
// if we use anything other than string down the road
Array.from(value.values()).map((v) =>
factory.createStringLiteral(v, true)
)
)
);
}
if (Array.isArray(value)) {
if (value[0] === SyntaxKind.CallExpression) {
// this handle the case where the property assignment is a fn call;
return factory.createPropertyAssignment(
factory.createIdentifier(key),
factory.createCallExpression(
factory.createIdentifier(value[1]),
undefined,
value[2]
)
);
} else if (value[0] === SyntaxKind.SpreadAssignment) {
return factory.createSpreadAssignment(
factory.createCallExpression(
factory.createIdentifier(key),
undefined,
value[1]
)
);
}
}
switch (typeof value) {
case 'number':
return factory.createPropertyAssignment(
factory.createIdentifier(key),
factory.createNumericLiteral(value)
);
case 'string':
return factory.createPropertyAssignment(
factory.createIdentifier(key),
factory.createStringLiteral(value, true)
);
case 'boolean':
return factory.createPropertyAssignment(
factory.createIdentifier(key),
value ? factory.createTrue() : factory.createFalse()
);
case 'object':
let configEntries = Object.entries(value);
if (value instanceof Map) {
configEntries = Array.from(value.entries());
}
return factory.createPropertyAssignment(
factory.createIdentifier(key),
factory.createObjectLiteralExpression(
configEntries
.map(([configKey, configValue]) => {
return createObjectAssignments(factory, configKey, configValue);
})
// make sure spread assignments go first, so they don't override the properties.
.sort((propA, propB) => (isSpreadAssignment(propA) ? -1 : 1)),
true
)
);
}
}
type scrollBehaviorOptions = false | 'center' | 'top' | 'bottom' | 'nearest';
/**
* Duplicate of Cypress.Config cypress config
* because when referencing the cypress types it breaks jest tests
*/
export interface InternalResolvedConfigOptions<ComponentDevServerOpts = any> {
baseUrl: string | null;
env: { [key: string]: any };
excludeSpecPattern: string | string[];
numTestsKeptInMemory: number;
port: number | null;
reporter: string;
reporterOptions: { [key: string]: any };
slowTestThreshold: number;
watchForFileChanges: boolean;
defaultCommandTimeout: number;
execTimeout: number;
pageLoadTimeout: number;
requestTimeout: number;
responseTimeout: number;
taskTimeout: number;
fileServerFolder: string;
fixturesFolder: string | false;
integrationFolder: string;
downloadsFolder: string;
nodeVersion: 'system' | 'bundled';
pluginsFile: string | false;
redirectionLimit: number;
resolvedNodePath: string;
resolvedNodeVersion: string;
screenshotOnRunFailure: boolean;
screenshotsFolder: string | false;
supportFile: string | false;
videosFolder: string;
trashAssetsBeforeRuns: boolean;
videoCompression: number | false;
video: boolean;
videoUploadOnPasses: boolean;
chromeWebSecurity: boolean;
viewportHeight: number;
viewportWidth: number;
animationDistanceThreshold: number;
waitForAnimations: boolean;
scrollBehavior: scrollBehaviorOptions;
experimentalSessionSupport: boolean;
experimentalInteractiveRunEvents: boolean;
experimentalSourceRewriting: boolean;
experimentalStudio: boolean;
retries: Nullable<
number | { runMode?: Nullable<number>; openMode?: Nullable<number> }
>;
includeShadowDom: boolean;
blockHosts: null | string | string[];
componentFolder: false | string;
projectId: null | string;
supportFolder: string;
specPattern: string | string[];
userAgent: null | string;
experimentalFetchPolyfill: boolean;
component: ComponentConfigOptions<ComponentDevServerOpts>;
e2e: CoreConfigOptions;
}
interface ComponentConfigOptions<ComponentDevServerOpts = any>
extends CoreConfigOptions {
devServer: DevServerFn<ComponentDevServerOpts>;
devServerConfig?: ComponentDevServerOpts;
}
type CoreConfigOptions = Partial<
Omit<InternalResolvedConfigOptions, TestingType>
>;
type DevServerFn<ComponentDevServerOpts = any> = (
cypressDevServerConfig: any,
devServerConfig: ComponentDevServerOpts
) => InternalResolvedConfigOptions | Promise<InternalResolvedConfigOptions>;
type TestingType = 'e2e' | 'component';
// TODO(caleb): this feels wrong? but unsure how else to use cypress types
// without causing issues in testing types from jest
/// <reference types="cypress" />
import {Node, SyntaxKind} from 'typescript';
export type CypressConfig = Cypress.ConfigOptions;
type CypressComponentProperties = keyof CypressConfig['component'];
type CypressE2EProperties = keyof CypressConfig['e2e'];
type CypressTopLevelProperties = Exclude<keyof CypressConfig,
'component' | 'e2e'>;
export type CypressConfigPropertyPath =
| `component.${CypressComponentProperties}`
| `e2e.${CypressE2EProperties}`
| CypressTopLevelProperties;
export function isBooleanLiteral(node: Node) {
return (
node.kind === SyntaxKind.TrueKeyword ||
node.kind === SyntaxKind.FalseKeyword
);
}
/**
* Intersection
* @desc From `T` pick properties that exist in `U`
* @example
* type Props = { name: string; age: number; visible: boolean };
* type DefaultProps = { age: number };
*
* // Expect: { age: number; }
* type DuplicateProps = Intersection<Props, DefaultProps>;
*/
export type Intersection<T extends object, U extends object> = Pick<
T,
Extract<keyof T, keyof U> & Extract<keyof U, keyof T>
>;
/**
* SetDifference (same as Exclude)
* @desc Set difference of given union types `A` and `B`
* @example
* // Expect: "1"
* SetDifference<'1' | '2' | '3', '2' | '3' | '4'>;
*
* // Expect: string | number
* SetDifference<string | number | (() => void), Function>;
*/
export type SetDifference<A, B> = A extends B ? never : A;
/**
* Diff
* @desc From `T` remove properties that exist in `U`
* @example
* type Props = { name: string; age: number; visible: boolean };
* type DefaultProps = { age: number };
*
* // Expect: { name: string; visible: boolean; }
* type DiffProps = Diff<Props, DefaultProps>;
*/
export type Diff<T extends object, U extends object> = Pick<
T,
SetDifference<keyof T, keyof U>
>;
/**
* Overwrite
* @desc From `U` overwrite properties to `T`
* @example
* type Props = { name: string; age: number; visible: boolean };
* type NewProps = { age: string; other: string };
*
* // Expect: { name: string; age: string; visible: boolean; }
* type ReplacedProps = Overwrite<Props, NewProps>;
*/
export type Overwrite<
T extends object,
U extends object,
I = Diff<T, U> & Intersection<U, T>
> = Pick<I, keyof I>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment