|
| 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