@revoloo/cypress6
Version:
Cypress.io end to end testing tool
201 lines (165 loc) • 5.95 kB
text/typescript
import {
Visitor,
namedTypes as n,
builders as b,
} from 'ast-types'
import { ExpressionKind } from 'ast-types/gen/kinds'
// use `globalThis` instead of `window`, `self`... to lower chances of scope conflict
// users can technically override even this, but it would be very rude
// "[globalThis] provides a way for polyfills/shims, build tools, and portable code to have a reliable non-eval means to access the global..."
// @see https://github.com/tc39/proposal-global/blob/master/NAMING.md
const globalIdentifier = b.identifier('globalThis')
/**
* Generate a CallExpression for Cypress.resolveWindowReference
* @param accessedObject object being accessed
* @param prop name of property being accessed
* @param maybeVal if an assignment is being made, this is the RHS of the assignment
*/
function resolveWindowReference (accessedObject: ExpressionKind, prop: string, maybeVal?: ExpressionKind) {
const args = [
globalIdentifier,
accessedObject,
b.stringLiteral(prop),
]
if (maybeVal) {
args.push(maybeVal)
}
return b.callExpression(
b.memberExpression(
b.memberExpression(
b.memberExpression(
globalIdentifier,
b.identifier('top'),
),
b.identifier('Cypress'),
),
b.identifier('resolveWindowReference'),
),
args,
)
}
/**
* Generate a CallExpression for Cypress.resolveLocationReference
*/
function resolveLocationReference () {
return b.callExpression(
b.memberExpression(
b.memberExpression(
b.memberExpression(
globalIdentifier,
b.identifier('top'),
),
b.identifier('Cypress'),
),
b.identifier('resolveLocationReference'),
),
[globalIdentifier],
)
}
/**
* Given an Identifier or a Literal, return a property name that should use `resolveWindowReference`.
* @param node
*/
function getReplaceablePropOfMemberExpression (node: n.MemberExpression) {
const { property } = node
// something.(top|parent)
if (n.Identifier.check(property) && ['parent', 'top', 'location'].includes(property.name)) {
return property.name
}
// something['(top|parent)']
if (n.Literal.check(property) && ['parent', 'top', 'location'].includes(String(property.value))) {
return String(property.value)
}
// NOTE: cases where a variable is used for the prop will not be replaced
// for example, `bar = 'top'; window[bar];` will not be replaced
// this would most likely be too slow
return
}
/**
* An AST Visitor that applies JS transformations required for Cypress.
* @see https://github.com/benjamn/ast-types#ast-traversal for details on how the Visitor is implemented
* @see https://astexplorer.net/#/gist/7f1e645c74df845b0e1f814454e9bbdf/f443b701b53bf17fbbf40e9285cb8b65a4066240
* to explore ASTs generated by recast
*/
export const jsRules: Visitor<{}> = {
// replace member accesses like foo['top'] or bar.parent with resolveWindowReference
visitMemberExpression (path) {
const { node } = path
const prop = getReplaceablePropOfMemberExpression(node)
if (!prop) {
return this.traverse(path)
}
path.replace(resolveWindowReference(path.get('object').node, prop))
return false
},
// replace lone identifiers like `top`, `parent`, with resolveWindowReference
visitIdentifier (path) {
const { node } = path
if (path.parentPath) {
const parentNode = path.parentPath.node
// like `identifer = 'foo'`
const isAssignee = n.AssignmentExpression.check(parentNode) && parentNode.left === node
if (isAssignee && node.name === 'location') {
// `location = 'something'`, rewrite to intercepted href setter since relative urls can break this
path.replace(b.memberExpression(resolveLocationReference(), b.identifier('href')))
return false
}
// some Identifiers do not refer to a scoped variable, depending on how they're used
if (
// like `var top = 'foo'`
(n.VariableDeclarator.check(parentNode) && parentNode.id === node)
|| (isAssignee)
|| (
[
'LabeledStatement', // like `top: foo();`
'ContinueStatement', // like 'continue top'
'BreakStatement', // like 'break top'
'Property', // like `{ top: 'foo' }`
'FunctionDeclaration', // like `function top()`
'RestElement', // like (...top)
'ArrowFunctionExpression', // like `(top, ...parent) => { }`
'ArrowExpression', // MDN Parser docs mention this being used for () => {}
'FunctionExpression', // like `(function top())`,
].includes(parentNode.type)
)
) {
return false
}
}
if (path.scope.declares(node.name)) {
// identifier has been declared in local scope, don't care about replacing
return this.traverse(path)
}
if (node.name === 'location') {
path.replace(resolveLocationReference())
return false
}
if (['parent', 'top'].includes(node.name)) {
path.replace(resolveWindowReference(globalIdentifier, node.name))
return false
}
this.traverse(path)
},
visitAssignmentExpression (path) {
const { node } = path
const finish = () => {
this.traverse(path)
}
if (!n.MemberExpression.check(node.left)) {
return finish()
}
const propBeingSet = getReplaceablePropOfMemberExpression(node.left)
if (!propBeingSet) {
return finish()
}
if (node.operator !== '=') {
// in the case of +=, -=, |=, etc., assume they're not doing something like
// `window.top += 4` since that would be invalid anyways, just continue down the RHS
this.traverse(path.get('right'))
return false
}
const objBeingSetOn = node.left.object
path.replace(resolveWindowReference(objBeingSetOn, propBeingSet, node.right))
return false
},
}