Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
23 changed files
with
607 additions
and
79 deletions.
There are no files selected for viewing
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
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
190
packages/config/src/ast-utils/addToCypressConfigPlugin.ts
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' | ||
) | ||
} |
Oops, something went wrong.