Skip to content

Commit

Permalink
wip on AST merging Cypress Config
Browse files Browse the repository at this point in the history
  • Loading branch information
tgriesser committed Apr 20, 2022
1 parent e1a4b3d commit d2e1124
Show file tree
Hide file tree
Showing 23 changed files with 607 additions and 79 deletions.
8 changes: 7 additions & 1 deletion packages/config/package.json
Expand Up @@ -12,19 +12,25 @@
"clean": "rimraf --glob ./src/*.js ./src/**/*.js ./src/**/**/*.js ./test/**/*.js || echo 'cleaned'",
"test": "yarn test-unit",
"test-debug": "yarn test-unit --inspect-brk=5566",
"test-unit": "mocha --configFile=../../mocha-reporter-config.json -r @packages/ts/register test/unit/**/*.spec.ts --exit"
"test-unit": "mocha --configFile=../../mocha-reporter-config.json -r @packages/ts/register test/**/*.spec.ts --exit"
},
"dependencies": {
"@babel/core": "^7",
"@babel/plugin-syntax-typescript": "^7",
"@babel/plugin-transform-typescript": "^7",
"@babel/types": "^7",
"check-more-types": "2.24.0",
"common-tags": "1.8.0",
"debug": "^4.3.2",
"fs-extra": "^9.1.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@packages/root": "0.0.0-development",
"@packages/ts": "0.0.0-development",
"@packages/types": "0.0.0-development",
"@types/mocha": "9.1.0",
"babel-plugin-tester": "^10.1.0",
"chai": "4.2.0",
"mocha": "7.0.1",
"rimraf": "3.0.2"
Expand Down
156 changes: 156 additions & 0 deletions packages/config/src/ast-utils/addToCypressConfig.ts
@@ -0,0 +1,156 @@
import * as t from '@babel/types'
import * as babel from '@babel/core'
import fs from 'fs-extra'
import dedent from 'dedent'
import path from 'path'

import { addToCypressConfigPlugin } from './addToCypressConfigPlugin'
import { addComponentDefinition, addE2EDefinition, ASTComponentDefinitionConfig } from './astConfigHelpers'

/**
* Adds to the Cypress config, using the Babel AST utils.
*
* Injects the at the top of the config definition, based on the common patterns of:
*
* export default { ...
*
* export default defineConfig({ ...
*
* module.exports = { ...
*
* module.exports = defineConfig({ ...
*
* export = { ...
*
* export = defineConfig({ ...
*
* If we don't match one of these, we'll use the rest-spread pattern on whatever
* the current default export of the file is:
*
* current:
* export default createConfigFn()
*
* becomes:
* export default {
* projectId: '...',
* ...createConfigFn()
* }
*/
export async function addToCypressConfig (code: string, toAdd: t.ObjectProperty) {
return babel.transformAsync(code, {
babelrc: false,
parserOpts: {
errorRecovery: true,
strictMode: false,
},
plugins: [
addToCypressConfigPlugin(toAdd),
],
})
}

export interface AddProjectIdToCypressConfigOptions {
filePath: string
projectId: string
}

export async function addProjectIdToCypressConfig (options: AddProjectIdToCypressConfigOptions) {
try {
let result = await fs.readFile(options.filePath, 'utf8')
const { code: toPrint } = await addToCypressConfig(result, t.objectProperty(
t.identifier('projectId'),
t.identifier(options.projectId),
))

await fs.writeFile(options.filePath, maybeFormatWithPrettier(toPrint, options.filePath))

return {
result: 'ADDED',
}
} catch (e) {
return {
result: 'NEEDS_MERGE',
error: e,
}
}
}

export interface AddTestingTypeToCypressConfigFile {
result: 'ADDED' | 'NEEDS_MERGE'
error?: Error
}

export interface AddTestingTypeToCypressConfigOptions extends AddProjectIdToCypressConfigOptions {
outputType: 'ts' | 'js' | 'esm'
info: ASTComponentDefinitionConfig | {
testingType: 'e2e'
}
}

export async function addTestingTypeToCypressConfig (options: AddTestingTypeToCypressConfigOptions): Promise<AddTestingTypeToCypressConfigFile> {
try {
let result: string

try {
result = await fs.readFile(options.filePath, 'utf8')
} catch {
//
}

// If for some reason they have deleted the contents of the file, we want to recover
// gracefully by adding some default code to use as the AST here, based on the outputType
if (!result || result.trim() === '') {
result = getEmptyCodeBlock(options.outputType)
}

const toAdd = options.info.testingType === 'e2e' ? addE2EDefinition() : addComponentDefinition(options.info)
const { code: toPrint } = await addToCypressConfig(result, toAdd)

await fs.writeFile(options.filePath, maybeFormatWithPrettier(toPrint, options.filePath))

return {
result: 'ADDED',
}
} catch (e) {
return {
result: 'NEEDS_MERGE',
error: e,
}
}
}

// Necessary to handle the edge case of them deleting the contents of their Cypress
// config file, just before we merge in the testing type
function getEmptyCodeBlock (outputType: 'js' | 'ts' | 'esm') {
if (outputType === 'js') {
return dedent`
const { defineConfig } = require('cypress')
module.exports = defineConfig({
})
`
}

return dedent`
import { defineConfig } from 'cypress'
export default defineConfig({
})
`
}

function maybeFormatWithPrettier (code: string, filePath: string) {
try {
const prettierImportPath = require.resolve('prettier', { paths: [path.dirname(filePath)] })
const prettier = require(prettierImportPath) as typeof import('prettier')

return prettier.format(code, {
filepath: filePath,
})
} catch {
//
return code
}
}
190 changes: 190 additions & 0 deletions packages/config/src/ast-utils/addToCypressConfigPlugin.ts
@@ -0,0 +1,190 @@
import type { ParserOptions, PluginItem, Visitor } from '@babel/core'
import * as t from '@babel/types'

/**
* Standardizes our approach to writing values into the existing
* Cypress config file. Attempts to handle the pragmatic cases,
* finding the
*
* @param toAdd k/v Object Property to append to the current object
* @returns
*/
export function addToCypressConfigPlugin (toAdd: t.ObjectProperty): PluginItem {
/**
* Based on the import syntax, we look for the "defineConfig" identifier, and whether it
* has been reassigned
*/
const defineConfigIdentifiers: Array<string | [string, string]> = []
/**
* Checks whether we've seen the identifier
*/
let seenConfigIdentifierCall = false

// Returns the ObjectExpression associated with the defineConfig call,
// so we can add in the "toAdd" object property
function getDefineConfigExpression (node: t.CallExpression): t.ObjectExpression | undefined {
for (const possibleIdentifier of defineConfigIdentifiers) {
if (typeof possibleIdentifier === 'string') {
if (t.isIdentifier(node.callee) && node.callee.name === possibleIdentifier && t.isObjectExpression(node.arguments[0])) {
return node.arguments[0]
}
} else if (Array.isArray(possibleIdentifier)) {
if (t.isMemberExpression(node.callee) &&
t.isIdentifier(node.callee.object) &&
t.isIdentifier(node.callee.property) &&
node.callee.object.name === possibleIdentifier[0] &&
node.callee.property.name === possibleIdentifier[1] &&
t.isObjectExpression(node.arguments[0])
) {
return node.arguments[0]
}
}
}

return undefined
}

// Visits the program ahead-of-time, to know what transforms we need to do
// on the source when we output the addition
const nestedVisitor: Visitor = {
ImportDeclaration (path) {
// Skip "import type" for the purpose of finding the defineConfig identifier,
// and skip if we see a non "cypress" import, since that's the only one we care about finding
if (path.node.importKind === 'type' || path.node.source.value !== 'cypress') {
return
}

for (const specifier of path.node.specifiers) {
if (specifier.type === 'ImportNamespaceSpecifier' || specifier.type === 'ImportDefaultSpecifier') {
defineConfigIdentifiers.push([specifier.local.name, 'defineConfig'])
} else {
defineConfigIdentifiers.push(specifier.local.name)
}
}
},
VariableDeclaration (path) {
// We only care about the top-level variable declarations for requires
if (path.parent.type !== 'Program') {
return
}

const cyImportDeclarations = path.node.declarations.filter((d) => {
return (
t.isCallExpression(d.init) &&
t.isIdentifier(d.init.callee) &&
d.init.callee.name === 'require' &&
t.isStringLiteral(d.init.arguments[0]) &&
d.init.arguments[0].value === 'cypress'
)
})

for (const variableDeclaration of cyImportDeclarations) {
if (t.isIdentifier(variableDeclaration.id)) {
defineConfigIdentifiers.push([variableDeclaration.id.name, 'defineConfig'])
} else if (t.isObjectPattern(variableDeclaration.id)) {
for (const prop of variableDeclaration.id.properties) {
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && t.isIdentifier(prop.value)) {
if (prop.key.name === 'defineConfig') {
defineConfigIdentifiers.push(prop.value.name)
}
}
}
}
}
},
CallExpression (path) {
if (getDefineConfigExpression(path.node)) {
seenConfigIdentifierCall = true
}
},
}

let didAdd = false

return {
name: 'addToCypressConfigPlugin',
manipulateOptions (t, parserOpts: ParserOptions) {
parserOpts.errorRecovery = true
if (
parserOpts.plugins.some(
(p: any) => (Array.isArray(p) ? p[0] : p) === 'typescript',
)
) {
return
}

parserOpts.plugins.push('typescript')
},
visitor: {
Program: {
enter (path) {
path.traverse(nestedVisitor)
},
exit () {
if (!didAdd) {
throw new Error('Unable to add the properties to the file')
}
},
},
CallExpression (path) {
if (seenConfigIdentifierCall && !didAdd) {
const defineConfigExpression = getDefineConfigExpression(path.node)

if (defineConfigExpression) {
defineConfigExpression.properties.push(toAdd)
didAdd = true
}
}
},
ExportDefaultDeclaration (path) {
// Exit if we've seen the defineConfig({ ... called elsewhere,
// since this is where we'll be adding the object props
if (seenConfigIdentifierCall || didAdd) {
return
}

// export default {}
if (t.isObjectExpression(path.node.declaration)) {
path.node.declaration.properties.push(toAdd)
didAdd = true
} else if (t.isExpression(path.node.declaration)) {
path.node.declaration = spreadResult(path.node.declaration, toAdd)
didAdd = true
}
},
AssignmentExpression (path) {
// Exit if we've seen the defineConfig({ ... called elsewhere,
// since this is where we'll be adding the object props
if (seenConfigIdentifierCall || didAdd) {
return
}

if (t.isMemberExpression(path.node.left) && isModuleExports(path.node.left)) {
if (t.isObjectExpression(path.node.right)) {
path.node.right.properties.push(toAdd)
didAdd = true
} else if (t.isExpression(path.node.right)) {
path.node.right = spreadResult(path.node.right, toAdd)
didAdd = true
}
}
},
},
}
}

function spreadResult (expr: t.Expression, toAdd: t.ObjectProperty): t.ObjectExpression {
return t.objectExpression([
t.spreadElement(expr),
toAdd,
])
}

function isModuleExports (node: t.MemberExpression) {
return (
t.isIdentifier(node.object) &&
node.object.name === 'module' &&
t.isIdentifier(node.property) &&
node.property.name === 'exports'
)
}

0 comments on commit d2e1124

Please sign in to comment.