Last active
July 14, 2022 13:39
-
-
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!)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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