Skip to content
This repository was archived by the owner on Sep 1, 2024. It is now read-only.

Commit

Permalink
[cypress] Add synchronous config wrapper for CommonJS
Browse files Browse the repository at this point in the history
  • Loading branch information
ramosbugs committed Jun 21, 2023
1 parent 300640e commit 5f8dd7c
Show file tree
Hide file tree
Showing 22 changed files with 295 additions and 147 deletions.
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,6 @@ This plugin maintains compatibility with the Cypress and Node.js versions listed
[![11.2.0+ | 12.0.0+](https://img.shields.io/badge/Cypress-11.2.0%2B%20%7C%2012.0.0%2B-17202C?logo=cypress&labelColor=white&logoColor=17202C&style=flat)](https://cypress.io)
[![16 | 18 | 20](https://img.shields.io/badge/Node.js-16%20%7C%2018%20%7C%2020-339933?logo=node.js&labelColor=white&logoColor=339933&style=flat)](https://nodejs.org)

For TypeScript projects, `typescript` version 4.7 or later is required, and `tsconfig.json` must set
[`moduleResolution`](https://www.typescriptlang.org/tsconfig#moduleResolution) to `node16` or
`nodenext`. This setting is required so that
[ES modules](https://nodejs.org/api/esm.html#modules-ecmascript-modules) resolve correctly.

## Jest Plugin

The Jest plugin enables users of the [Jest](https://jestjs.io) JavaScript test framework
Expand Down
5 changes: 0 additions & 5 deletions packages/cypress-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ This plugin maintains compatibility with the Cypress and Node.js versions listed
[![11.2.0+ | 12.0.0+](https://img.shields.io/badge/Cypress-11.2.0%2B%20%7C%2012.0.0%2B-17202C?logo=cypress&labelColor=white&logoColor=17202C&style=flat)](https://cypress.io)
[![16 | 18 | 20](https://img.shields.io/badge/Node.js-16%20%7C%2018%20%7C%2020-339933?logo=node.js&labelColor=white&logoColor=339933&style=flat)](https://nodejs.org)

For TypeScript projects, `typescript` version 4.7 or later is required, and `tsconfig.json` must set
[`moduleResolution`](https://www.typescriptlang.org/tsconfig#moduleResolution) to `node16` or
`nodenext`. This setting is required so that
[ES modules](https://nodejs.org/api/esm.html#modules-ecmascript-modules) resolve correctly.

## Contributing

To report a bug or request a new feature, please
Expand Down
7 changes: 6 additions & 1 deletion packages/cypress-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"types": "./dist/config-wrapper.d.ts",
"default": "./dist/config-wrapper.mjs"
},
"./config-wrapper-sync": {
"types": "./dist/config-wrapper-sync.d.ts",
"default": "./dist/config-wrapper-sync.js"
},
"./reporter": {
"types": "./dist/reporter.d.ts",
"default": "./dist/reporter.js"
Expand All @@ -33,6 +37,7 @@
"dist/**/*.mjs",
"dist/index.d.ts",
"dist/config-wrapper.d.ts",
"dist/config-wrapper-sync.d.ts",
"dist/reporter.d.ts",
"dist/skip-tests.d.ts"
],
Expand Down Expand Up @@ -86,7 +91,7 @@
},
"scripts": {
"build": "yarn clean && tsc --noEmit && tsc --noEmit -p src && yarn build:cjs && yarn build:esm",
"build:cjs": "rollup --config --dir dist",
"build:cjs": "rollup --config --dir dist && rollup --config --input src/config-wrapper-sync.ts --file dist/config-wrapper-sync.js --chunkFileNames \"[name]-[hash]-sync.js\"",
"build:esm": "rollup --config --input src/config-wrapper.ts --file dist/config-wrapper.mjs --format es --chunkFileNames \"[name]-[hash].mjs\"",
"clean": "rm -rf dist/",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --useStderr --verbose"
Expand Down
12 changes: 12 additions & 0 deletions packages/cypress-plugin/src/config-wrapper-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2023 Developer Innovations, LLC

import { wrapCypressConfig } from "./index";
import _debug from "debug";
import { loadUserConfigSync } from "./load-user-config";

const debug = _debug("unflakable:config-wrapper-sync");

const userConfig = loadUserConfigSync();
debug("Loaded user config %o", userConfig);

export default wrapCypressConfig(userConfig);
49 changes: 1 addition & 48 deletions packages/cypress-plugin/src/config-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,10 @@

import { wrapCypressConfig } from "./index";
import _debug from "debug";
import { require } from "./utils";
import {
ENV_VAR_USER_CONFIG_JSON,
ENV_VAR_USER_CONFIG_PATH,
} from "./config-env-vars";
import path from "path";
import { loadUserConfig } from "./load-user-config";

const debug = _debug("unflakable:config-wrapper");

type LoadedConfig =
| {
default: Cypress.ConfigOptions<unknown>;
}
| (Cypress.ConfigOptions<unknown> & { default: undefined });

const loadUserConfig = async (): Promise<Cypress.ConfigOptions<unknown>> => {
if (ENV_VAR_USER_CONFIG_JSON.value !== undefined) {
debug(`Parsing inline user config ${ENV_VAR_USER_CONFIG_JSON.value}`);

return JSON.parse(
ENV_VAR_USER_CONFIG_JSON.value
) as Cypress.ConfigOptions<unknown>;
} else if (ENV_VAR_USER_CONFIG_PATH.value === undefined) {
throw new Error("No user config to load");
}

debug(`Loading user config from ${ENV_VAR_USER_CONFIG_PATH.value}`);

// Relative paths from the user's config need to resolve relative to the location of their
// cypress.config.js, not ours. This affects things like webpack for component testing.
const configPathDir = path.dirname(ENV_VAR_USER_CONFIG_PATH.value);
debug(`Changing working directory to ${configPathDir}`);
process.chdir(configPathDir);

// For CommonJS projects, we need to use require(), at least for TypeScript config files.
// Dynamic import() doesn't support TypeScript imports in CommonJS projects, at least the way
// Cypress sets up the environment before loading the config.
try {
const config = require(ENV_VAR_USER_CONFIG_PATH.value) as LoadedConfig;
return config.default ?? config;
} catch (e) {
// require() can't import ES modules, so now we try a dynamic import(). This is what gets used
// for ESM projects.
debug(`require() failed; attempting dynamic import(): ${e as string}`);
const config = (await import(
ENV_VAR_USER_CONFIG_PATH.value
)) as LoadedConfig;
return config.default ?? config;
}
};

const userConfig = await loadUserConfig();
debug("Loaded user config %o", userConfig);

Expand Down
62 changes: 62 additions & 0 deletions packages/cypress-plugin/src/load-user-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2023 Developer Innovations, LLC

import _debug from "debug";
import { require } from "./utils";
import {
ENV_VAR_USER_CONFIG_JSON,
ENV_VAR_USER_CONFIG_PATH,
} from "./config-env-vars";
import path from "path";

const debug = _debug("unflakable:load-user-config");

export type LoadedConfig =
| {
default: Cypress.ConfigOptions<unknown>;
}
| (Cypress.ConfigOptions<unknown> & { default: undefined });

export const loadUserConfigSync = (): Cypress.ConfigOptions<unknown> => {
if (ENV_VAR_USER_CONFIG_JSON.value !== undefined) {
debug(`Parsing inline user config ${ENV_VAR_USER_CONFIG_JSON.value}`);

return JSON.parse(
ENV_VAR_USER_CONFIG_JSON.value
) as Cypress.ConfigOptions<unknown>;
} else if (ENV_VAR_USER_CONFIG_PATH.value === undefined) {
throw new Error("No user config to load");
}

debug(`Loading user config from ${ENV_VAR_USER_CONFIG_PATH.value}`);

// Relative paths from the user's config need to resolve relative to the location of their
// cypress.config.js, not ours. This affects things like webpack for component testing.
const configPathDir = path.dirname(ENV_VAR_USER_CONFIG_PATH.value);
debug(`Changing working directory to ${configPathDir}`);
process.chdir(configPathDir);

// For CommonJS projects, we need to use require(), at least for TypeScript config files.
// Dynamic import() doesn't support TypeScript imports in CommonJS projects, at least the way
// Cypress sets up the environment before loading the config.
const config = require(ENV_VAR_USER_CONFIG_PATH.value) as LoadedConfig;
return config.default ?? config;
};

export const loadUserConfig = async (): Promise<
Cypress.ConfigOptions<unknown>
> => {
// For CommonJS projects, we need to use require(), at least for TypeScript config files.
// Dynamic import() doesn't support TypeScript imports in CommonJS projects, at least the way
// Cypress sets up the environment before loading the config.
try {
return loadUserConfigSync();
} catch (e) {
// require() can't import ES modules, so now we try a dynamic import(). This is what gets used
// for ESM projects.
debug(`require() failed; attempting dynamic import(): ${e as string}`);
const config = (await import(
ENV_VAR_USER_CONFIG_PATH.value as string
)) as LoadedConfig;
return config.default ?? config;
}
};
104 changes: 71 additions & 33 deletions packages/cypress-plugin/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
} from "./config-env-vars";

const CONFIG_WRAPPER_MODULE = "@unflakable/cypress-plugin/config-wrapper";
const CONFIG_WRAPPER_SYNC_MODULE =
"@unflakable/cypress-plugin/config-wrapper-sync";

const debug = _debug("unflakable:main");

Expand Down Expand Up @@ -266,6 +268,8 @@ const main = async (): Promise<void> => {
const unflakableConfig = await loadConfig(projectRoot, args["test-suite-id"]);
debug(`Unflakable plugin is ${unflakableConfig.enabled ? "en" : "dis"}abled`);

let configFile: string | undefined = undefined;

if (unflakableConfig.enabled) {
if (args.branch !== undefined) {
branchOverride.value = args.branch;
Expand Down Expand Up @@ -293,43 +297,77 @@ const main = async (): Promise<void> => {
JSON.stringify(unflakableConfig);

if (args["auto-config"]) {
let userConfigPath: string | undefined = undefined;
if (runOptions.config !== undefined) {
ENV_VAR_USER_CONFIG_JSON.value = JSON.stringify(runOptions.config);
} else {
const userConfigPath = await resolveUserConfigPath(
projectRoot,
runOptions
);
userConfigPath = await resolveUserConfigPath(projectRoot, runOptions);
ENV_VAR_USER_CONFIG_PATH.value = userConfigPath;
}

// By default, Cypress invokes ts-node on CommonJS TypeScript projects by setting `dir`
// (deprecated alias for `cwd`) to the directory containing the Cypress config file:
// https://github.com/cypress-io/cypress/blob/62f58e00ec0e1f95bc0db3c644638e4882b91992/packages/server/lib/plugins/child/ts_node.js#L63

// For both ESM and CommonJS TypeScript projects, Cypress invokes ts-node with the CWD set
// to that directory:
// https://github.com/cypress-io/cypress/blob/62f58e00ec0e1f95bc0db3c644638e4882b91992/packages/data-context/src/data/ProjectConfigIpc.ts#L260

// Since we're passing our `config-wrapper.js` as the Cypress config, the CWD becomes our
// dist/ directory. However, we need ts-node to load the user's tsconfig.json, not our own,
// or the user's cypress.config.ts file may not load properly when we require()/import()
// it.
// To accomplish this, we try to discover the user's tsconfig.json by traversing the
// ancestor directories containing the user's Cypress config file. This is the same
// approach TypeScript uses:
// https://github.com/microsoft/TypeScript/blob/2beeb8b93143f75cdf788d05bb3678ce3ff0e2b3/src/compiler/program.ts#L340-L345

// If we find a tsconfig.json, we set the TS_NODE_PROJECT environment variable to the
// directory containing it, which ts-node then uses instead of searching the `dir` passed by
// Cypress.
const userTsConfig = await findUserTsConfig(
path.dirname(userConfigPath)
);
if (userTsConfig !== null) {
const tsNodeProject = path.dirname(userTsConfig);
debug(`Setting TS_NODE_PROJECT to ${tsNodeProject}`);
process.env.TS_NODE_PROJECT = tsNodeProject;
}
// By default, Cypress invokes ts-node on CommonJS TypeScript projects by setting `dir`
// (deprecated alias for `cwd`) to the directory containing the Cypress config file:
// https://github.com/cypress-io/cypress/blob/62f58e00ec0e1f95bc0db3c644638e4882b91992/packages/server/lib/plugins/child/ts_node.js#L63

// For both ESM and CommonJS TypeScript projects, Cypress invokes ts-node with the CWD set
// to that directory:
// https://github.com/cypress-io/cypress/blob/62f58e00ec0e1f95bc0db3c644638e4882b91992/packages/data-context/src/data/ProjectConfigIpc.ts#L260

// Since we're passing our `config-wrapper.js` as the Cypress config, the CWD becomes our
// dist/ directory. However, we need ts-node to load the user's tsconfig.json, not our own,
// or the user's cypress.config.ts file may not load properly when we require()/import()
// it.
// To accomplish this, we try to discover the user's tsconfig.json by traversing the
// ancestor directories containing the user's Cypress config file. This is the same
// approach TypeScript uses:
// https://github.com/microsoft/TypeScript/blob/2beeb8b93143f75cdf788d05bb3678ce3ff0e2b3/src/compiler/program.ts#L340-L345

// If we find a tsconfig.json, we set the TS_NODE_PROJECT environment variable to the
// directory containing it, which ts-node then uses instead of searching the `dir` passed by
// Cypress.
const userTsConfig = await findUserTsConfig(
path.dirname(userConfigPath ?? projectRoot)
);
if (userTsConfig !== null) {
const tsNodeProject = path.dirname(userTsConfig);
debug(`Setting TS_NODE_PROJECT to ${tsNodeProject}`);
process.env.TS_NODE_PROJECT = tsNodeProject;
}

// ESM configuration files can only be imported via dynamic import(), which is async.
// However,
// CommonJS files can't have top-level await, which means we can't have a CommonJS wrapper
// (config-wrapper-sync) import an ESM configuration file. For that, we use the ESM config
// wrapper. However, the ESM wrapper doesn't work for CommonJS TypeScript projects unless
// they explicitly set `moduleResolution: node16` (or `nodenext`), which we don't want to
// require from users. This is why we detect whether the project and/or config file are ESM
// when deciding which config wrapper to use. We assume that if the Cypress config file is
// .mjs/.mts, the TypeScript project (if any) is already using `node16`/`nodenext` (since
// otherwise, the project's own Cypress config file wouldn't work).
const userConfigIsEsm =
userConfigPath !== undefined
? [".mjs", ".mts"].includes(path.extname(userConfigPath))
: false;

// Try to determine whether the project is using ESM, as Cypress does. See
// https://github.com/cypress-io/cypress/blob/62f58e00ec0e1f95bc0db3c644638e4882b91992/packages/data-context/src/data/ProjectConfigIpc.ts#L276-L285
try {
const pkgJson = JSON.parse(
(await fs.readFile(path.join(projectRoot, "package.json"))).toString(
"utf8"
)
) as { type?: string };

configFile =
pkgJson.type === "module" || userConfigIsEsm
? require.resolve(CONFIG_WRAPPER_MODULE)
: require.resolve(CONFIG_WRAPPER_SYNC_MODULE);
} catch (e) {
// Project does not have `package.json` or it was not found.
// Reasonable to assume not using es modules unless config is explicitly ESM.
configFile = userConfigIsEsm
? require.resolve(CONFIG_WRAPPER_MODULE)
: require.resolve(CONFIG_WRAPPER_SYNC_MODULE);
}
}

Expand All @@ -344,7 +382,7 @@ const main = async (): Promise<void> => {
...runOptions,
...(args["auto-config"]
? {
configFile: require.resolve(CONFIG_WRAPPER_MODULE),
configFile,
}
: {}),
quiet: true,
Expand Down
14 changes: 1 addition & 13 deletions packages/cypress-plugin/test/integration-common/package.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
{
"name": "cypress-integration-common",
"private": true,
"exports": {
"./config": {
"types": "./dist/config.d.ts",
"default": "./dist/config.js"
},
"./git": {
"types": "./dist/git.d.ts",
"default": "./dist/git.js"
},
"./mock-cosmiconfig": {
"default": "./dist/mock-cosmiconfig.js"
}
},
"dependencies": {
"debug": "^4.3.3",
"expect": "^29.5.0",
Expand All @@ -25,6 +12,7 @@
"@rollup/plugin-typescript": "^11.1.1",
"@unflakable/plugins-common": "workspace:^",
"rollup": "^3.21.1",
"rollup-plugin-dts": "^5.3.0",
"typescript": "^4.9.5"
},
"scripts": {
Expand Down
Loading

0 comments on commit 5f8dd7c

Please sign in to comment.