Skip to content

Commit f2100a8

Browse files
authored
test(system-tests): support npm for test projects (#20664)
1 parent 8c8875b commit f2100a8

File tree

17 files changed

+2731
-1816
lines changed

17 files changed

+2731
-1816
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ system-tests/fixtures/large-img
101101

102102
# Building app binary
103103
scripts/support
104-
package-lock.json
105104
binary-url.json
106105

107106
# Allows us to dynamically create eslint rules that override the default for Decaffeinate scripts

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@
3939
// These are commented out because they slow down node development
4040
// "volar.autoCompleteRefs": false,
4141
"volar.takeOverMode.enabled": true,
42+
43+
"editor.tabSize": 2,
4244
}

circle.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ commands:
312312
key: v{{ .Environment.CACHE_VERSION }}-{{ checksum "platform_key" }}-deps-root-weekly-{{ checksum "cache_date" }}
313313
paths:
314314
- ~/.yarn
315+
- ~/.cy-npm-cache
315316

316317
verify-build-setup:
317318
description: Common commands run when setting up for build or yarn install

packages/server/test/integration/plugins_spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ require('../spec_helper')
22

33
const plugins = require('../../lib/plugins')
44
const Fixtures = require('@tooling/system-tests/lib/fixtures')
5+
const { scaffoldCommonNodeModules } = require('@tooling/system-tests/lib/dep-installer')
56

67
const pluginsFile = Fixtures.projectPath('plugin-before-browser-launch-deprecation/cypress/plugins/index.js')
78

89
describe('lib/plugins', () => {
910
beforeEach(async () => {
1011
Fixtures.scaffoldProject('plugin-before-browser-launch-deprecation')
11-
await Fixtures.scaffoldCommonNodeModules()
12+
await scaffoldCommonNodeModules()
1213
})
1314

1415
afterEach(() => {

scripts/binary/smoke.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const Promise = require('bluebird')
77
const os = require('os')
88
const verify = require('../../cli/lib/tasks/verify')
99
const Fixtures = require('@tooling/system-tests/lib/fixtures')
10+
const { scaffoldCommonNodeModules } = require('@tooling/system-tests/lib/dep-installer')
1011

1112
const fs = Promise.promisifyAll(fse)
1213

@@ -160,7 +161,7 @@ const runFailingProjectTest = function (buildAppExecutable, e2e) {
160161
}
161162

162163
const test = async function (buildAppExecutable) {
163-
await Fixtures.scaffoldCommonNodeModules()
164+
await scaffoldCommonNodeModules()
164165
Fixtures.scaffoldProject('e2e')
165166
const e2e = Fixtures.projectPath('e2e')
166167

system-tests/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,20 @@ SNAPSHOT_UPDATE=1 yarn test go_spec
103103

104104
Every folder in [`./projects`](./lib/projects) represents a self-contained Cypress project. When you pass the `project` property to `systemTests.it` or `systemTests.exec`, Cypress launches using this project.
105105

106-
If a test project has a `package.json` file, the `systemTests.exec` helper will attempt to install the correct `node_modules` by running `yarn install` against the project. This is cached in CI and locally to speed up test times.
106+
If a test project has a `package.json` file, the `systemTests.exec` helper will attempt to install the correct `node_modules` by running `yarn install` or `npm install` (depending on which lockfile is present) against the project. This is cached in CI and locally to speed up test times.
107107

108108
`systemTests.exec` *copies* the project directory to a temporary folder outside of the monorepo root. This means that temporary projects will not inherit the `node_modules` from this package or the monorepo. So, you must add the dependencies required for your project in `dependencies` or `devDependencies`.
109109

110110
The exception is some commonly used packages that are scaffolded for all projects, like `lodash` and `debug`. You can see the list by looking at `scaffoldCommonNodeModules` in [`./lib/fixtures.ts`](./lib/fixtures.ts) These packages do not need to be added to a test project's `package.json`.
111111

112-
You can also set special properties in a test project's `package.json` to influence the helper's behavior when running `yarn`:
112+
You can also set special properties in a test project's `package.json` to influence the helper's behavior when running `yarn` or `npm`:
113113

114114
`package.json` Property Name | Type | Description
115115
--- | --- | ---
116-
`_cySkipYarnInstall` | `boolean` | If `true`, skip the automatic `yarn install` for this package, even though it has a `package.json`.
116+
`_cySkipDepInstall` | `boolean` | If `true`, skip the automatic `yarn install` or `npm install` for this package, even though it has a `package.json`.
117117
`_cyYarnV311` | `boolean` | Run the yarn v3.1.1-style install command instead of yarn v1-style.
118-
`_cyRunScripts` | `boolean` | By default, the automatic `yarn install` will not run postinstall scripts. This option, if set, will cause postinstall scripts to run for this project.
118+
`_cyRunScripts` | `boolean` | By default, the automatic install will not run postinstall scripts. This option, if set, will cause postinstall scripts to run for this project.
119119

120-
Run `yarn projects:yarn:install` to run `yarn install` for all projects with a `package.json`.
120+
Run `yarn projects:yarn:install` to run `yarn install`/`npm install` for all applicable projects.
121121

122-
Use the `UPDATE_YARN_LOCK=1` environment variable with `yarn test` or `yarn projects:yarn:install` to allow the `yarn.lock` to be updated and synced back to the monorepo from the temp dir.
122+
Use the `UPDATE_LOCK_FILE=1` environment variable with `yarn test` or `yarn projects:yarn:install` to allow the `yarn.lock` or `package-lock.json` to be updated and synced back to the monorepo from the temp dir.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import fs from 'fs-extra'
2+
import path from 'path'
3+
import cachedir from 'cachedir'
4+
import execa from 'execa'
5+
import { cyTmpDir, projectPath, projects, root } from '../fixtures'
6+
import { getYarnCommand } from './yarn'
7+
import { getNpmCommand } from './npm'
8+
9+
type Dependencies = Record<string, string>
10+
11+
/**
12+
* Type for package.json files for system-tests example projects.
13+
*/
14+
type SystemTestPkgJson = {
15+
/**
16+
* By default, scaffolding will run install if there is a `package.json`.
17+
* This option, if set, disables that.
18+
*/
19+
_cySkipDepInstall?: boolean
20+
/**
21+
* Run the yarn v3-style install command instead of yarn v1-style.
22+
*/
23+
_cyYarnV311?: boolean
24+
/**
25+
* By default, the automatic install will not run postinstall scripts. This
26+
* option, if set, will cause postinstall scripts to run for this project.
27+
*/
28+
_cyRunScripts?: boolean
29+
dependencies?: Dependencies
30+
devDependencies?: Dependencies
31+
optionalDependencies?: Dependencies
32+
}
33+
34+
const log = (...args) => console.log('📦', ...args)
35+
36+
/**
37+
* Given a package name, returns the path to the module directory on disk.
38+
*/
39+
function pathToPackage (pkg: string): string {
40+
return path.dirname(require.resolve(`${pkg}/package.json`))
41+
}
42+
43+
async function ensureCacheDir (cacheDir: string) {
44+
try {
45+
await fs.stat(cacheDir)
46+
} catch (err) {
47+
log(`Creating a new node_modules cache dir at ${cacheDir}`)
48+
await fs.mkdirp(cacheDir)
49+
}
50+
}
51+
52+
/**
53+
* Symlink the cached `node_modules` directory to the temp project directory's `node_modules`.
54+
*/
55+
async function symlinkNodeModulesFromCache (tmpNodeModulesDir: string, cacheDir: string): Promise<void> {
56+
await fs.symlink(cacheDir, tmpNodeModulesDir, 'junction')
57+
58+
log(`node_modules symlink created at ${tmpNodeModulesDir}`)
59+
}
60+
61+
/**
62+
* Copy the cached `node_modules` to the temp project directory's `node_modules`.
63+
*
64+
* @returns a callback that will copy changed `node_modules` back to the cached `node_modules`.
65+
*/
66+
async function copyNodeModulesFromCache (tmpNodeModulesDir: string, cacheDir: string): Promise<() => Promise<void>> {
67+
await fs.copy(cacheDir, tmpNodeModulesDir, { dereference: true })
68+
69+
log(`node_modules copied to ${tmpNodeModulesDir} from cache dir ${cacheDir}`)
70+
71+
return async () => {
72+
try {
73+
await fs.copy(tmpNodeModulesDir, cacheDir, { dereference: true })
74+
} catch (err) {
75+
if (err.message === 'Source and destination must not be the same') return
76+
77+
throw err
78+
}
79+
80+
log(`node_modules copied from ${tmpNodeModulesDir} to cache dir ${cacheDir}`)
81+
}
82+
}
83+
84+
async function getLockFilename (dir: string) {
85+
const hasYarnLock = !!await fs.stat(path.join(dir, 'yarn.lock')).catch(() => false)
86+
const hasNpmLock = !!await fs.stat(path.join(dir, 'package-lock.json')).catch(() => false)
87+
88+
if (hasYarnLock && hasNpmLock) throw new Error(`The example project at '${dir}' has conflicting lockfiles. Only use one package manager's lockfile per project.`)
89+
90+
if (hasNpmLock) return 'package-lock.json'
91+
92+
// default to yarn
93+
return 'yarn.lock'
94+
}
95+
96+
function getRelativePathToProjectDir (projectDir: string) {
97+
return path.relative(projectDir, path.join(root, '..'))
98+
}
99+
100+
async function restoreLockFileRelativePaths (opts: { projectDir: string, lockFilePath: string, relativePathToMonorepoRoot: string }) {
101+
const relativePathToProjectDir = getRelativePathToProjectDir(opts.projectDir)
102+
const lockFileContents = (await fs.readFile(opts.lockFilePath, 'utf8'))
103+
.replaceAll(opts.relativePathToMonorepoRoot, relativePathToProjectDir)
104+
105+
await fs.writeFile(opts.lockFilePath, lockFileContents)
106+
}
107+
108+
async function normalizeLockFileRelativePaths (opts: { project: string, projectDir: string, lockFilePath: string, lockFilename: string, relativePathToMonorepoRoot: string }) {
109+
const relativePathToProjectDir = getRelativePathToProjectDir(opts.projectDir)
110+
const lockFileContents = (await fs.readFile(opts.lockFilePath, 'utf8'))
111+
.replaceAll(relativePathToProjectDir, opts.relativePathToMonorepoRoot)
112+
113+
// write back to the original project dir, not the tmp copy
114+
await fs.writeFile(path.join(projects, opts.project, opts.lockFilename), lockFileContents)
115+
}
116+
117+
/**
118+
* Given a path to a `package.json`, convert any references to development
119+
* versions of packages to absolute paths, so `yarn`/`npm` will not reach out to
120+
* the Internet to obtain these packages once it runs in the temp dir.
121+
* @returns a list of dependency names that were updated
122+
*/
123+
async function makeWorkspacePackagesAbsolute (pathToPkgJson: string): Promise<string[]> {
124+
const pkgJson = await fs.readJson(pathToPkgJson)
125+
const updatedDeps: string[] = []
126+
127+
for (const deps of [pkgJson.dependencies, pkgJson.devDependencies, pkgJson.optionalDependencies]) {
128+
for (const dep in deps) {
129+
const version = deps[dep]
130+
131+
if (version.startsWith('file:')) {
132+
const absPath = pathToPackage(dep)
133+
134+
log(`Setting absolute path in package.json for ${dep}: ${absPath}.`)
135+
136+
deps[dep] = `file:${absPath}`
137+
updatedDeps.push(dep)
138+
}
139+
}
140+
}
141+
142+
await fs.writeJson(pathToPkgJson, pkgJson)
143+
144+
return updatedDeps
145+
}
146+
147+
/**
148+
* Given a `system-tests` project name, detect and install the `node_modules`
149+
* specified in the project's `package.json`. No-op if no `package.json` is found.
150+
* Will use `yarn` or `npm` based on the lockfile present.
151+
*/
152+
export async function scaffoldProjectNodeModules (project: string, updateLockFile: boolean = !!process.env.UPDATE_LOCK_FILE): Promise<void> {
153+
const projectDir = projectPath(project)
154+
const relativePathToMonorepoRoot = path.relative(
155+
path.join(projects, project),
156+
path.join(root, '..'),
157+
)
158+
const projectPkgJsonPath = path.join(projectDir, 'package.json')
159+
160+
const runCmd = async (cmd) => {
161+
log(`Running "${cmd}" in ${projectDir}`)
162+
await execa(cmd, { cwd: projectDir, stdio: 'inherit', shell: true })
163+
}
164+
165+
const cacheNodeModulesDir = path.join(cachedir('cy-system-tests-node-modules'), project, 'node_modules')
166+
const tmpNodeModulesDir = path.join(projectPath(project), 'node_modules')
167+
168+
async function removeWorkspacePackages (packages: string[]): Promise<void> {
169+
for (const dep of packages) {
170+
const depDir = path.join(tmpNodeModulesDir, dep)
171+
172+
log('Removing', depDir)
173+
await fs.remove(depDir)
174+
}
175+
}
176+
177+
try {
178+
// this will throw and exit early if the package.json does not exist
179+
const pkgJson: SystemTestPkgJson = require(projectPkgJsonPath)
180+
181+
log(`Found package.json for project ${project}.`)
182+
183+
if (pkgJson._cySkipDepInstall) {
184+
return log(`_cySkipDepInstall set in package.json, skipping dep-installer steps`)
185+
}
186+
187+
if (!pkgJson.dependencies && !pkgJson.devDependencies && !pkgJson.optionalDependencies) {
188+
return log(`No dependencies found, skipping dep-installer steps`)
189+
}
190+
191+
const lockFilename = await getLockFilename(projectDir)
192+
const hasYarnLock = lockFilename === 'yarn.lock'
193+
194+
// 1. Ensure there is a cache directory set up for this test project's `node_modules`.
195+
await ensureCacheDir(cacheNodeModulesDir)
196+
197+
let persistCacheCb: () => Promise<void>
198+
199+
if (hasYarnLock) {
200+
await symlinkNodeModulesFromCache(tmpNodeModulesDir, cacheNodeModulesDir)
201+
} else {
202+
// due to an issue in NPM, we cannot have `node_modules` be a symlink. fall back to copying.
203+
// https://github.com/npm/npm/issues/10013
204+
persistCacheCb = await copyNodeModulesFromCache(tmpNodeModulesDir, cacheNodeModulesDir)
205+
}
206+
207+
// 2. Before running the package installer, resolve workspace deps to absolute paths.
208+
// This is required to fix install for workspace-only packages.
209+
const workspaceDeps = await makeWorkspacePackagesAbsolute(projectPkgJsonPath)
210+
211+
// 3. Delete cached workspace packages since the pkg manager will create a fresh symlink during install.
212+
await removeWorkspacePackages(workspaceDeps)
213+
214+
// 4. Fix relative paths in temp dir's lockfile.
215+
const lockFilePath = path.join(projectDir, lockFilename)
216+
217+
log(`Writing ${lockFilename} with fixed relative paths to temp dir`)
218+
await restoreLockFileRelativePaths({ projectDir, lockFilePath, relativePathToMonorepoRoot })
219+
220+
// 5. Run `yarn/npm install`.
221+
const getCommandFn = hasYarnLock ? getYarnCommand : getNpmCommand
222+
const cmd = getCommandFn({
223+
updateLockFile,
224+
yarnV311: pkgJson._cyYarnV311,
225+
isCI: !!process.env.CI,
226+
runScripts: pkgJson._cyRunScripts,
227+
})
228+
229+
await runCmd(cmd)
230+
231+
// 6. Now that the lockfile is up to date, update workspace dependency paths in the lockfile with monorepo
232+
// relative paths so it can be the same for all developers
233+
log(`Copying ${lockFilename} and fixing relative paths for ${project}`)
234+
await normalizeLockFileRelativePaths({ project, projectDir, lockFilePath, lockFilename, relativePathToMonorepoRoot })
235+
236+
// 7. After install, we must now symlink *over* all workspace dependencies, or else
237+
// `require` calls from installed workspace deps to peer deps will fail.
238+
await removeWorkspacePackages(workspaceDeps)
239+
for (const dep of workspaceDeps) {
240+
const destDir = path.join(tmpNodeModulesDir, dep)
241+
const targetDir = pathToPackage(dep)
242+
243+
log(`Symlinking workspace dependency: ${dep} (${destDir} -> ${targetDir})`)
244+
245+
await fs.mkdir(path.dirname(destDir), { recursive: true })
246+
await fs.symlink(targetDir, destDir, 'junction')
247+
}
248+
249+
// 8. If necessary, ensure that the `node_modules` cache is updated by copying `node_modules` back.
250+
if (persistCacheCb) await persistCacheCb()
251+
} catch (err) {
252+
if (err.code === 'MODULE_NOT_FOUND') return
253+
254+
console.error(`⚠ An error occurred while installing the node_modules for ${project}.`)
255+
console.error(err)
256+
throw err
257+
}
258+
}
259+
260+
/**
261+
* Create symlinks to very commonly used (in example projects) `node_modules`.
262+
*
263+
* This is done because many `projects` use the same modules, like `lodash`, and it's not worth it
264+
* to increase CI install times just to have it explicitly specified by `package.json`. A symlink
265+
* is faster than a real `npm install`.
266+
*
267+
* Adding modules here *decreases the quality of test coverage* because it allows test projects
268+
* to make assumptions about what modules are available that don't hold true in the real world. So
269+
* *do not add a module here* unless you are really sure that it should be available in every
270+
* single test project.
271+
*/
272+
export async function scaffoldCommonNodeModules () {
273+
await Promise.all([
274+
'@cypress/code-coverage',
275+
'@cypress/webpack-dev-server',
276+
'@packages/socket',
277+
'@packages/ts',
278+
'@tooling/system-tests',
279+
'bluebird',
280+
'chai',
281+
'dayjs',
282+
'debug',
283+
'execa',
284+
'fs-extra',
285+
'https-proxy-agent',
286+
'jimp',
287+
'lazy-ass',
288+
'lodash',
289+
'proxyquire',
290+
'react',
291+
'semver',
292+
'systeminformation',
293+
'tslib',
294+
'typescript',
295+
].map(symlinkNodeModule))
296+
}
297+
298+
async function symlinkNodeModule (pkg) {
299+
const from = path.join(cyTmpDir, 'node_modules', pkg)
300+
const to = pathToPackage(pkg)
301+
302+
await fs.ensureDir(path.dirname(from))
303+
try {
304+
await fs.symlink(to, from, 'junction')
305+
} catch (err) {
306+
if (err.code === 'EEXIST') return
307+
308+
throw err
309+
}
310+
}

0 commit comments

Comments
 (0)