Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: implement experimental ESM stub/spy for Vite #26536

Merged
merged 51 commits into from
Apr 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f36b5ae
wip - no verify
lmiller1990 Apr 14, 2023
99f9c77
add tests
lmiller1990 Apr 16, 2023
7a981c2
remove old files
lmiller1990 Apr 16, 2023
585bc21
old code
lmiller1990 Apr 16, 2023
f379848
fix bug with illegal property def
lmiller1990 Apr 17, 2023
133fecc
update config
lmiller1990 Apr 17, 2023
691a67d
spies
lmiller1990 Apr 18, 2023
5fc6ed9
update
lmiller1990 Apr 18, 2023
3631bc9
fix bugs
lmiller1990 Apr 18, 2023
2542c07
caching
lmiller1990 Apr 18, 2023
149295a
update name of package
lmiller1990 Apr 18, 2023
b4960a5
fix bug
lmiller1990 Apr 18, 2023
0ff31e2
debugging
lmiller1990 Apr 19, 2023
0ed8348
rename
lmiller1990 Apr 19, 2023
efee5e2
handle edge cases with more advanced syntax
lmiller1990 Apr 19, 2023
0b2fe72
apply transform globally
lmiller1990 Apr 19, 2023
2e6c4c7
rename package
lmiller1990 Apr 19, 2023
28f6733
revert name change
lmiller1990 Apr 19, 2023
d1e81f0
update readme
lmiller1990 Apr 19, 2023
ecb3139
add test for other assets
lmiller1990 Apr 19, 2023
9ca77b2
Merge remote-tracking branch 'origin/develop' into issue-564-esm
lmiller1990 Apr 19, 2023
ef1affe
update yarn.lock
lmiller1990 Apr 19, 2023
ec16a68
chore: updating v8 snapshot cache
Apr 19, 2023
cc37d37
revert lock file
lmiller1990 Apr 19, 2023
fbd11ff
add test command
lmiller1990 Apr 19, 2023
555605b
Merge branch 'issue-564-esm' of https://github.com/cypress-io/cypress…
lmiller1990 Apr 19, 2023
36c1cb0
chore: updating v8 snapshot cache
Apr 19, 2023
45e7f5f
chore: updating v8 snapshot cache
Apr 19, 2023
5a3615b
update README
lmiller1990 Apr 19, 2023
ad5e893
better comments
lmiller1990 Apr 19, 2023
45c2002
update package.json
lmiller1990 Apr 20, 2023
befd9dc
handle edge case for new class instances
lmiller1990 Apr 20, 2023
d3819df
Merge branch 'issue-564-esm' of https://github.com/cypress-io/cypress…
lmiller1990 Apr 20, 2023
4cad299
add edge case
lmiller1990 Apr 20, 2023
74ac1b7
Fix function prototype edge case
mike-plummer Apr 20, 2023
b8fe483
Handle wildcard import syntax
mike-plummer Apr 20, 2023
4179a2d
edge case for arrays
lmiller1990 Apr 21, 2023
d123e37
ignore list
lmiller1990 Apr 21, 2023
f33dc9a
log
lmiller1990 Apr 21, 2023
6a4665c
add notes
lmiller1990 Apr 21, 2023
7f227c6
add edge case
lmiller1990 Apr 21, 2023
a56e2a0
merge oprigin
lmiller1990 Apr 21, 2023
416c542
revert snapshot updates
lmiller1990 Apr 24, 2023
c223209
add docs on known issues
lmiller1990 Apr 24, 2023
6551833
docs
lmiller1990 Apr 24, 2023
245330b
lock version
lmiller1990 Apr 24, 2023
f625ce6
update name
lmiller1990 Apr 24, 2023
7e13042
fix comments
lmiller1990 Apr 24, 2023
15642c3
Update README
mike-plummer Apr 24, 2023
0df9ddd
Apply suggestions from code review
mike-plummer Apr 24, 2023
d3cf76b
Merge branch 'develop' into issue-564-esm
mike-plummer Apr 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions npm/vite-plugin-cypress-esm/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**/dist
**/*.d.ts
**/package-lock.json
**/tsconfig.json
**/cypress/fixtures
32 changes: 32 additions & 0 deletions npm/vite-plugin-cypress-esm/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"plugins": [
"cypress",
"@cypress/dev"
],
"extends": [
"plugin:@cypress/dev/general",
"plugin:@cypress/dev/tests",
"plugin:@cypress/dev/react"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser"
},
"env": {
"cypress/globals": true
},
"rules": {
"no-console": "off",
"mocha/no-global-tests": "off",
"react/jsx-filename-extension": [
"warn",
{
"extensions": [
".js",
".jsx",
".tsx"
]
}
]
}
}
1 change: 1 addition & 0 deletions npm/vite-plugin-cypress-esm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cypress/videos/*
Empty file.
123 changes: 123 additions & 0 deletions npm/vite-plugin-cypress-esm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# @cypress/vite-plugin-cypress-esm

A Vite plugin that intercepts and rewrites ES module imports within [Cypress component tests](https://docs.cypress.io/guides/component-testing/overview). The [ESM specification](https://tc39.es/ecma262/#sec-modules) generates modules that are "sealed", requiring the runtime (the browser) to prevent any alteration to the module namespace. While this has security and performance benefits, it prevents use of mocking libraries which would need to replace namespace members. This plugin wraps modules in a special [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) implementation, allowing for instrumentation by libraries such as Sinon.

> **Note:** This package is a pre-release alpha and is not yet stable. There are likely to be bugs and edge cases. Please report any bugs [here](https://github.com/cypress-io/cypress/issues/new?labels=npm:%20@cypress/vite-plugin-cypress-esm). [Learn more about Cypress release stages](https://docs.cypress.io/guides/references/release-stages#Alpha) and expectations around stability.

## Debugging

Run Cypress with `DEBUG=cypress:vite-plugin-cypress-esm`. You will get logs in the terminal, for the code transformation, and in the browser console, for intercepting and wrapping the modules in a Proxy.
## Compatibility

| @cypress/vite-plugin-mock-esm | cypress |
| ------------------------ | ------- |
| >= v1 | >= v12 |

## Usage

This plugin rewrites the ES modules served by Vite to make them mutable and therefore compatible with methods like [`cy.spy()`](https://docs.cypress.io/api/commands/spy) and [`cy.stub()`](https://docs.cypress.io/api/commands/stub) that require modifying otherwise-sealed objects. Since this is a testing-specific plugin it is recommended to apply it your Vite config only when running your Cypress tests. One way to do so would be in `cypress.config`:

```ts
import { defineConfig } from 'cypress'
import viteConfig from './vite.config'
import { mergeConfig } from 'vite'
import { CypressEsm } from '@cypress/vite-plugin-cypress-esm'

export default defineConfig({
component: {
devServer: {
bundler: 'vite',
framework: 'react',
viteConfig: () => {
return mergeConfig(
viteConfig,
{
plugins: [
CypressEsm(),
]
}
),
}
},
}
})
```

### `ignoreList`

Some modules may be incompatible with Proxy-based implementation. The eventual goal is to support wrapping all modules in a Proxy to better facilitate testing. For now, if you run into any issues with a particular module, you can add it to the `ignoreList` like so:

```ts
CypressEsm({
ignoreList: ['react-router', 'react-router-dom']
})
```

You can also use a glob, which uses [`picomatch`](https://github.com/micromatch/picomatch) internally:

```ts
CypressEsm({
ignoreList: ['*react*']
})
```

React is known to have some conflicts with the Proxy implementation that cause problems stubbing internal React functionality. Since it is unlikely you want to stub parts of React itself, it's a good idea to add it to the `ignoreList`.

## Known Issues

### Import Syntax

All known [import syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) is supported, however there may edge cases that have not been identified.

### Regular Expression matching

This module uses Regular Expression matching to transform the modules on the server to facilitate wrapping them in a `Proxy` on the client. In future updates, a more robust AST-based approach will be explored. A limitation of the current approach is that it does not recognize syntax from actual code vs content found within strings (for instance, an error string that contains example code syntax). This can result in inappropriately modified string constants.

### Auto-hosting

ESM imports are automatically hoisted to the top of a given module so they happen first before any code that references them. This plugin does not currently perform any hoisting, so imports are transformed to variable references in place. If you have code that attempts to reference an imported value prior to that import it will likely break. This is a known issue with HMR logic in Svelte projects, and will typically present as a "use before define" error.

### Self-references and internal calls

This plugin works by intercepting calls coming *in* to a module. This will not work for situations where a module attempts to make *internal* calls to a function within the same module or directly compare against a function within the same module. Eg:

```js
// mod_1.js
export function foo () {
// ...
}

export function bar (mod) {
return mod === foo
}

// mod_2.js
import { foo, bar } from './mod_1.js'

bar(foo) //=> false
```

lmiller1990 marked this conversation as resolved.
Show resolved Hide resolved
In this example, `bar(foo)` is passing a reference to `mod_1.foo`, where `mod_1` is a module wrapped in a `Proxy`. In the original `mod_1.js`, the reference to `foo` is the original, unwrapped `foo`, so the comparison return `false`. This may cause issues in some libraries, such as React Router when lazy loading routes. You can add modules to `ignoreList` to work around this issue.

### Sinon compatibility

This plugin is designed to work with [Sinon](https://sinonjs.org/) since that is what Cypress uses internally for `cy.stub` and `cy.spy` - attempting to utilize other stubbing/mocking libraries or directly mutating modules is not a supported use case and will likely not work as expected.

## Troubleshooting

This is an **_Alpha_** release, meaning there a very likely bugs in the implementation and it is expected that you will encounter issues. We appreciate any bug reports once you have performed the troubleshooting process below.

If you encounter issues:
1. Ensure you're using the very latest version of this Plugin and Cypress
2. Try temporarily removing this plugin from your test's Vite config - if the issue is still present then it is not related to this plugin.
3. Verify you have not encountered one of the [Known Issues](#known-issues)
3. If the issue disappeared then try narrowing down if it's related to a specific module/dependency by using the `ignoreList` config
4. If your problem isn't related to a specific dependency and can't be isolated please file a bug report [here](https://github.com/cypress-io/cypress/issues/new?labels=npm:%20@cypress/vite-plugin-cypress-esm). A reproduction case project is extremely helpful to track down specific issues, and capturing [Debug Logs](#debugging) from both your terminal *and* the browser devtools console is very helpful.

## License

[![license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cypress-io/cypress/blob/develop/LICENSE)

This project is licensed under the terms of the [MIT license](/LICENSE).

## [Changelog](./CHANGELOG.md)
191 changes: 191 additions & 0 deletions npm/vite-plugin-cypress-esm/client/moduleCache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
const __cypressModuleCache = new Map()
lmiller1990 marked this conversation as resolved.
Show resolved Hide resolved

const NO_REDEFINE_LIST = new Set(['prototype'])

let debug = false

function createProxyModule (module) {
// What we build our module proxy off of depends on whether the module has a default export
// We need to be able to support `import DefaultValue from 'module'` => `const DefaultValue = __cypressModule(module)`
const base = module.default || module
let target

// Work around for the fact that a module with a default export needs to work the same way via object destructuring
// for this module remapping concept to work
// ```
// import TheDefault from 'module'
// `TheDefault` could be an object or a function
// ```
if (typeof base === 'function') {
target = function (...params) {
if (typeof target.default === 'function') {
return target.default.apply(this, params)
}

if (typeof module === 'function') {
return module.apply(this, params)
}
}
} else {
target = {}
}

const proxies = {}

function redefinePropertyDescriptors (module, overrides) {
Object.entries(Object.getOwnPropertyDescriptors(module)).forEach(([key, descriptor]) => {
if (Array.isArray(module)) {
return
}

if (NO_REDEFINE_LIST.has(key)) {
log(`⏭️ Skipping ${key}`)

return
}

log(`🧪 Redefining ${key}`)

Object.defineProperty(target, key, {
...descriptor,
...overrides,
})

if (typeof descriptor.value === 'function') {
// This is how you can see if something is a class
// Playground: https://regex101.com/r/OS2Iyg/1
// Important! RegEx instances are stateful, do not extract to a constant
const isClass = /class.+?\{.+?\}/gms.test(descriptor.value.toString())

if (isClass) {
log(`🏗️ Handling ${key} as a constructor`)

proxies[key] = function (...params) {
// Edge case - use `apply` with `new` to create a class instance
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/construct
return Reflect.construct(target[key], params)
}
} else {
log(`🎁 Handling ${key} with a standard wrapper function`)

proxies[key] = function (...params) {
return target[key].apply(this, params)
}
}

proxies[key].prototype = target[key].prototype
}
})
}

// Do not proxify arrays - you can't spy on an array, no need.
if (Array.isArray(module.default)) {
return module.default
}

if (module.default && typeof module.default !== 'function') {
redefinePropertyDescriptors(module.default, {
writable: true,
enumerable: true,
})
}

redefinePropertyDescriptors(module, {
configurable: true,
writable: true,
})

const moduleProxy = new Proxy(target, {
get (_, prop, receiver) {
const value = target[prop]

if (typeof value === 'function') {
// Check to see if this retrieval is coming from a sinon `spy` creation
// If so, we want to supply the 'true' function rather than our proxied version
// so the spy can call through to the real implementation
const stack = new Error().stack

if (stack?.includes('Sandbox.spy')) {
log(`🕵️ Detected ${prop} is being defined as a Sinon spy`)

return value
}

// Otherwise, return our proxied function implementation
return proxies[prop]
}

return target[prop]
},
set (obj, prop, value) {
target[prop] = value

if (typeof value === 'function' && !(prop in proxies)) {
proxies[prop] = function (...params) {
return target[prop].apply(this, params)
}
Comment on lines +124 to +126
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This duplicates the function wrapping we do on line 45, could pull out a wrapFunction helper just to make sure they stay aligned

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think about it I can't remember why I found this block necessary. Theoretically all functions should have been proxied up front 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make this refactor, it's quite awkward and I think it's a little less clear to read. Any recommendation for how you think it could be better written?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering now that we have more custom logic in this area if we'll need to apply it (eg constructor handling) in other places we're building function wrappers - there's like 4 places we're generating wrappers, hard to keep track of which ones need to account for what.

Copy link
Contributor Author

@lmiller1990 lmiller1990 Apr 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably rejigger this a bit now - we've got decent enough test coverage.

Would also be good to get this merged prior to the sprint ending - I could go either way. Hard to say how much more we will work on this in the near future, given the caveats we've run into (like the reference vs proxy comparison that might be unsolvable).

}

return true
},
defineProperty (_, key, descriptor) {
// Ignore `define` attempts to set a sinon proxy, but return true anyways
// Allowing define would blow away our function proxy
// Sinon circles back and attempts to set via `set` anyways so this isn't necessary
if (descriptor.value?.isSinonProxy) {
return true
}

Object.defineProperty(target, key, { ...descriptor, writable: true, configurable: true })

return true
},
deleteProperty (_, prop) {
// Don't allow deletion - Sinon tries to delete things as a cleanup activity which breaks our proxied functions

return true
},
})

return moduleProxy
}

function log (msg) {
if (!debug) {
return
}

console.log(`[cypress:vite-plugin-mock-esm]: ${msg}`)
}

function cacheAndProxifyModule (id, module) {
if (__cypressModuleCache.has(module)) {
return __cypressModuleCache.get(module)
}

log(`🔨 creating proxy module for ${id}`)

const moduleProxy = createProxyModule(module)

log(`✅ created proxy module for ${id}`)

__cypressModuleCache.set(module, moduleProxy)

log(`📈 Module cache now contains ${__cypressModuleCache.size} entries`)

return moduleProxy
}

window.__cypressDynamicModule = function (id, importPromise, _debug = false) {
debug = _debug

return Promise.resolve(importPromise.then((module) => {
return cacheAndProxifyModule(id, module)
}))
}

window.__cypressModule = function (id, module, _debug = false) {
debug = _debug

return cacheAndProxifyModule(id, module)
}
27 changes: 27 additions & 0 deletions npm/vite-plugin-cypress-esm/cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig } from 'cypress'
import react from '@vitejs/plugin-react'
import { CypressEsm } from './src'

export default defineConfig({
projectId: 'ypt4pf',
component: {
supportFile: false,
specPattern: 'cypress/component/**/*.cy.ts*',
devServer: {
bundler: 'vite',
framework: 'react',
viteConfig: () => {
return {
plugins: [
react({
jsxRuntime: 'classic',
}),
CypressEsm({
ignoreList: ['*Immutable*', '*MyAsync*'],
}),
],
}
},
},
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import './fixtures/style.css'
import { add } from './fixtures/add'

it('does not transform non JS assets', () => {
expect(add(1, 2)).to.eq(3)
})
Loading