-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: implement experimental ESM stub/spy for Vite (#26536)
* wip - no verify * add tests * remove old files * old code * fix bug with illegal property def * update config * spies * update * fix bugs * caching * update name of package * fix bug * debugging * rename * handle edge cases with more advanced syntax * apply transform globally * rename package * revert name change * update readme * add test for other assets * update yarn.lock * chore: updating v8 snapshot cache * revert lock file * add test command * chore: updating v8 snapshot cache * chore: updating v8 snapshot cache * update README Co-authored-by: Mike Plummer <mike-plummer@users.noreply.github.com> * better comments Co-authored-by: Mike Plummer <mike-plummer@users.noreply.github.com> * update package.json * handle edge case for new class instances * add edge case * Fix function prototype edge case * Copy function prototypes across when proxying * Add more debug logging, ensure logs are guarded by `debug` flag * Improve perf of constructor function detection * Fix potential nil access during spy detection * Fix logger name * Handle wildcard import syntax * edge case for arrays * ignore list * log * add notes * add edge case * add docs on known issues * docs * lock version * update name * fix comments * Update README * Apply suggestions from code review Co-authored-by: Mark Noonan <mark@cypress.io> --------- Co-authored-by: cypress-bot[bot] <+cypress-bot[bot]@users.noreply.github.com> Co-authored-by: Mike Plummer <mike-plummer@users.noreply.github.com> Co-authored-by: Mike Plummer <mikep@cypress.io> Co-authored-by: Mark Noonan <mark@cypress.io>
- Loading branch information
1 parent
d6f525c
commit 466155c
Showing
34 changed files
with
1,322 additions
and
13 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
**/dist | ||
**/*.d.ts | ||
**/package-lock.json | ||
**/tsconfig.json | ||
**/cypress/fixtures |
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,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" | ||
] | ||
} | ||
] | ||
} | ||
} |
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 @@ | ||
cypress/videos/* |
Empty file.
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,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 | ||
``` | ||
|
||
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) |
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,191 @@ | ||
const __cypressModuleCache = new Map() | ||
|
||
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) | ||
} | ||
} | ||
|
||
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) | ||
} |
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,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*'], | ||
}), | ||
], | ||
} | ||
}, | ||
}, | ||
}, | ||
}) |
6 changes: 6 additions & 0 deletions
6
npm/vite-plugin-cypress-esm/cypress/component/assetTypes.cy.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,6 @@ | ||
import './fixtures/style.css' | ||
import { add } from './fixtures/add' | ||
|
||
it('does not transform non JS assets', () => { | ||
expect(add(1, 2)).to.eq(3) | ||
}) |
Oops, something went wrong.
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux arm64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
darwin x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
win32 x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
darwin arm64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
466155c
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally: