From 367e08129aea4a3099eb49986f90668e39a142e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=A3=9E=E4=BE=A0?= <1037028848@qq.com> Date: Thu, 30 Jan 2020 01:18:23 +0800 Subject: [PATCH] feat: support yarn workspaces (#250) Co-authored-by: Mark Lee --- src/cli.ts | 5 +- src/electron-locator.ts | 45 ++++------ src/rebuild.ts | 40 ++++++--- src/search-module.ts | 86 +++++++++++++++++++ test/electron-locator.ts | 16 ++-- .../packages/bar/package.json | 1 + .../packages/foo/package.json | 1 + .../child-workspace/package.json | 16 ++++ test/fixture/workspace-test/package.json | 6 ++ test/rebuild-yarnworkspace.ts | 46 ++++++++++ test/search-module.ts | 49 +++++++++++ 11 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 src/search-module.ts create mode 100644 test/fixture/multi-level-workspace/packages/bar/package.json create mode 100644 test/fixture/multi-level-workspace/packages/foo/package.json create mode 100644 test/fixture/workspace-test/child-workspace/package.json create mode 100644 test/fixture/workspace-test/package.json create mode 100644 test/rebuild-yarnworkspace.ts create mode 100644 test/search-module.ts diff --git a/src/cli.ts b/src/cli.ts index 2ddc3d22..9fd7ebe7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import ora from 'ora'; import * as argParser from 'yargs'; import { rebuild, ModuleType } from './rebuild'; +import { getProjectRootPath } from './search-module'; import { locateElectronModule } from './electron-locator'; const yargs = argParser @@ -68,7 +69,8 @@ process.on('unhandledRejection', handler); (async (): Promise => { - const electronModulePath = argv.e ? path.resolve(process.cwd(), (argv.e as string)) : locateElectronModule(); + const projectRootPath = await getProjectRootPath(process.cwd()); + const electronModulePath = argv.e ? path.resolve(process.cwd(), (argv.e as string)) : await locateElectronModule(projectRootPath); let electronModuleVersion = argv.v as string; if (!electronModuleVersion) { @@ -128,6 +130,7 @@ process.on('unhandledRejection', handler); mode: argv.p ? 'parallel' : (argv.s ? 'sequential' : undefined), debug: argv.b as boolean, prebuildTagPrefix: (argv.prebuildTagPrefix as string) || 'v', + projectRootPath, }); const lifecycle = rebuilder.lifecycle; diff --git a/src/electron-locator.ts b/src/electron-locator.ts index 58d660c0..a61da5d4 100644 --- a/src/electron-locator.ts +++ b/src/electron-locator.ts @@ -1,38 +1,31 @@ -import * as fs from 'fs'; +import * as fs from 'fs-extra'; import * as path from 'path'; +import { searchForModule } from './search-module'; const electronModuleNames = ['electron', 'electron-prebuilt', 'electron-prebuilt-compile']; -const relativeNodeModulesDir = path.resolve(__dirname, '..', '..'); -function locateModules(pathMapper: (moduleName: string) => string | null): string[] { - const possibleModulePaths = electronModuleNames.map(pathMapper); - return possibleModulePaths.filter((modulePath) => modulePath && fs.existsSync(path.join(modulePath, 'package.json'))) as string[]; -} - -function locateSiblingModules(): string[] { - return locateModules((moduleName) => path.join(relativeNodeModulesDir, moduleName)); -} - -function locateModulesByRequire(): string[] | null { - return locateModules((moduleName) => { +async function locateModuleByRequire(): Promise { + for (const moduleName of electronModuleNames) { try { - return path.resolve(require.resolve(path.join(moduleName, 'package.json')), '..'); - } catch (error) { - return null; + const modulePath = path.resolve(require.resolve(path.join(moduleName, 'package.json')), '..'); + if (await fs.pathExists(path.join(modulePath, 'package.json'))) { + return modulePath; + } + } catch (_error) { // eslint-disable-line no-empty } - }); + } + + return null } -export function locateElectronModule(): string | null { - const siblingModules: string[] | null = locateSiblingModules(); - if (siblingModules.length > 0) { - return siblingModules[0]; - } +export async function locateElectronModule(projectRootPath?: string): Promise { + for (const moduleName of electronModuleNames) { + const electronPath = await searchForModule(process.cwd(), moduleName, projectRootPath)[0]; - const requiredModules = locateModulesByRequire(); - if (requiredModules && requiredModules.length > 0) { - return requiredModules[0]; + if (electronPath && await fs.pathExists(path.join(electronPath, 'package.json'))) { + return electronPath; + } } - return null; + return locateModuleByRequire(); } diff --git a/src/rebuild.ts b/src/rebuild.ts index f2770036..8f91a752 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -9,6 +9,7 @@ import * as os from 'os'; import * as path from 'path'; import { readPackageJson } from './read-package-json'; import { lookupModuleState, cacheModuleState } from './cache'; +import { searchForModule, searchForNodeModules } from './search-module'; export type ModuleType = 'prod' | 'dev' | 'optional'; export type RebuildMode = 'sequential' | 'parallel'; @@ -27,6 +28,7 @@ export interface RebuildOptions { useCache?: boolean; cachePath?: string; prebuildTagPrefix?: string; + projectRootPath?: string; } export type HashTree = { [path: string]: string | HashTree }; @@ -84,6 +86,7 @@ class Rebuilder { public useCache: boolean; public cachePath: string; public prebuildTagPrefix: string; + public projectRootPath?: string; constructor(options: RebuilderOptions) { this.lifecycle = options.lifecycle; @@ -105,6 +108,7 @@ class Rebuilder { console.warn('[WARNING]: Electron Rebuild has force enabled and cache enabled, force take precedence and the cache will not be used.'); this.useCache = false; } + this.projectRootPath = options.projectRootPath; if (typeof this.electronVersion === 'number') { if (`${this.electronVersion}`.split('.').length === 1) { @@ -118,7 +122,7 @@ class Rebuilder { } this.ABI = nodeAbi.getAbi(this.electronVersion, 'electron'); - this.prodDeps = this.extraModules.reduce((acc, x) => acc.add(x), new Set()); + this.prodDeps = this.extraModules.reduce((acc: Set, x: string) => acc.add(x), new Set()); this.rebuilds = []; this.realModulePaths = new Set(); this.realNodeModulesPaths = new Set(); @@ -158,14 +162,28 @@ class Rebuilder { for (const key of depKeys) { this.prodDeps[key] = true; - markWaiters.push(this.markChildrenAsProdDeps(path.resolve(this.buildPath, 'node_modules', key))); + const modulePaths: string[] = await searchForModule( + this.buildPath, + key, + this.projectRootPath + ); + for (const modulePath of modulePaths) { + markWaiters.push(this.markChildrenAsProdDeps(modulePath)); + } } await Promise.all(markWaiters); d('identified prod deps:', this.prodDeps); - await this.rebuildAllModulesIn(path.resolve(this.buildPath, 'node_modules')); + const nodeModulesPaths = await searchForNodeModules( + this.buildPath, + this.projectRootPath + ); + for (const nodeModulesPath of nodeModulesPaths) { + await this.rebuildAllModulesIn(nodeModulesPath); + } + this.rebuilds.push(() => this.rebuildModuleAt(this.buildPath)); if (this.mode !== 'sequential') { @@ -448,17 +466,13 @@ class Rebuilder { } async findModule(moduleName: string, fromDir: string, foundFn: ((p: string) => Promise)): Promise { - let targetDir = fromDir; - const foundFns = []; - while (targetDir !== path.dirname(this.buildPath)) { - const testPath = path.resolve(targetDir, 'node_modules', moduleName); - if (await fs.pathExists(testPath)) { - foundFns.push(foundFn(testPath)); - } - - targetDir = path.dirname(targetDir); - } + const testPaths = await searchForModule( + fromDir, + moduleName, + this.projectRootPath + ); + const foundFns = testPaths.map(testPath => foundFn(testPath)); return Promise.all(foundFns); } diff --git a/src/search-module.ts b/src/search-module.ts new file mode 100644 index 00000000..0158b44f --- /dev/null +++ b/src/search-module.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; + +async function shouldContinueSearch(traversedPath: string, rootPath?: string, stopAtPackageJSON?: boolean): Promise { + if (rootPath) { + return Promise.resolve(traversedPath !== path.dirname(rootPath)); + } else if (stopAtPackageJSON) { + return fs.pathExists(path.join(traversedPath, 'package.json')); + } else { + return true; + } +} + +type PathGeneratorFunction = (traversedPath: string) => string; + +async function traverseAncestorDirectories( + cwd: string, + pathGenerator: PathGeneratorFunction, + rootPath?: string, + maxItems?: number, + stopAtPackageJSON?: boolean +): Promise { + const paths: string[] = []; + let traversedPath = path.resolve(cwd); + + while (await shouldContinueSearch(traversedPath, rootPath, stopAtPackageJSON)) { + const generatedPath = pathGenerator(traversedPath); + if (await fs.pathExists(generatedPath)) { + paths.push(generatedPath); + } + + const parentPath = path.dirname(traversedPath); + if (parentPath === traversedPath || (maxItems && paths.length >= maxItems)) { + break; + } + traversedPath = parentPath; + } + + return paths; +} + +/** + * Find all instances of a given module in node_modules subdirectories while traversing up + * ancestor directories. + * + * @param cwd the initial directory to traverse + * @param moduleName the Node module name (should work for scoped modules as well) + * @param rootPath the project's root path. If provided, the traversal will stop at this path. + */ +export async function searchForModule( + cwd: string, + moduleName: string, + rootPath?: string +): Promise { + const pathGenerator: PathGeneratorFunction = (traversedPath) => path.join(traversedPath, 'node_modules', moduleName); + return traverseAncestorDirectories(cwd, pathGenerator, rootPath, undefined, true); +} + +/** + * Find all instances of node_modules subdirectories while traversing up ancestor directories. + * + * @param cwd the initial directory to traverse + * @param rootPath the project's root path. If provided, the traversal will stop at this path. + */ +export async function searchForNodeModules(cwd: string, rootPath?: string): Promise { + const pathGenerator: PathGeneratorFunction = (traversedPath) => path.join(traversedPath, 'node_modules'); + return traverseAncestorDirectories(cwd, pathGenerator, rootPath, undefined, true); +} + +/** + * Determine the root directory of a given project, by looking for a directory with an + * NPM or yarn lockfile. + * + * @param cwd the initial directory to traverse + */ +export async function getProjectRootPath(cwd: string): Promise { + for (const lockFilename of ['yarn.lock', 'package-lock.json']) { + const pathGenerator: PathGeneratorFunction = (traversedPath) => path.join(traversedPath, lockFilename); + const lockPaths = await traverseAncestorDirectories(cwd, pathGenerator, undefined, 1) + if (lockPaths.length > 0) { + return path.dirname(lockPaths[0]); + } + } + + return cwd; +} diff --git a/test/electron-locator.ts b/test/electron-locator.ts index ef99d903..dc9e4431 100644 --- a/test/electron-locator.ts +++ b/test/electron-locator.ts @@ -16,11 +16,11 @@ const install: ((s: string) => Promise) = packageCommand.bind(null, 'insta const uninstall: ((s: string) => Promise) = packageCommand.bind(null, 'uninstall'); const testElectronCanBeFound = (): void => { - it('should return a valid path', () => { - const electronPath = locateElectronModule(); + it('should return a valid path', async () => { + const electronPath = await locateElectronModule(); expect(electronPath).to.be.a('string'); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(fs.existsSync(electronPath!)).to.be.equal(true); + expect(await fs.pathExists(electronPath!)).to.be.equal(true); }); }; @@ -31,10 +31,10 @@ describe('locateElectronModule', function() { it('should return null when electron is not installed', async () => { await fs.remove(path.resolve(__dirname, '..', 'node_modules', 'electron')); - expect(locateElectronModule()).to.be.equal(null); + expect(await locateElectronModule()).to.be.equal(null); }); - describe('with electron-prebuilt installed', () => { + describe('with electron-prebuilt installed', async () => { before(() => install('electron-prebuilt')); testElectronCanBeFound(); @@ -42,13 +42,13 @@ describe('locateElectronModule', function() { after(() => uninstall('electron-prebuilt')); }); - describe('with electron installed', () => { - before(() => install('electron')); + describe('with electron installed', async () => { + before(() => install('electron@^5.0.13')); testElectronCanBeFound(); after(() => uninstall('electron')); }); - after(() => install('electron')); + after(() => install('electron@^5.0.13')); }); diff --git a/test/fixture/multi-level-workspace/packages/bar/package.json b/test/fixture/multi-level-workspace/packages/bar/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/test/fixture/multi-level-workspace/packages/bar/package.json @@ -0,0 +1 @@ +{} diff --git a/test/fixture/multi-level-workspace/packages/foo/package.json b/test/fixture/multi-level-workspace/packages/foo/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/test/fixture/multi-level-workspace/packages/foo/package.json @@ -0,0 +1 @@ +{} diff --git a/test/fixture/workspace-test/child-workspace/package.json b/test/fixture/workspace-test/child-workspace/package.json new file mode 100644 index 00000000..7b49dcbf --- /dev/null +++ b/test/fixture/workspace-test/child-workspace/package.json @@ -0,0 +1,16 @@ +{ + "name": "workspace-app", + "productName": "Workspace App", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "ffi-napi": "2.4.5" + }, + "dependencies": { + "ref-napi": "1.4.2" + } +} diff --git a/test/fixture/workspace-test/package.json b/test/fixture/workspace-test/package.json new file mode 100644 index 00000000..01c2237b --- /dev/null +++ b/test/fixture/workspace-test/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "workspaces": [ + "child-workspace" + ] +} diff --git a/test/rebuild-yarnworkspace.ts b/test/rebuild-yarnworkspace.ts new file mode 100644 index 00000000..42abda49 --- /dev/null +++ b/test/rebuild-yarnworkspace.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnPromise } from 'spawn-rx'; + +import { expectNativeModuleToBeRebuilt, expectNativeModuleToNotBeRebuilt } from './helpers/rebuild'; +import { rebuild } from '../src/rebuild'; +import { getProjectRootPath } from '../src/search-module'; + +describe('rebuild for yarn workspace', function() { + this.timeout(2 * 60 * 1000); + const testModulePath = path.resolve(os.tmpdir(), 'electron-rebuild-test'); + + describe('core behavior', () => { + before(async () => { + await fs.remove(testModulePath); + await fs.copy(path.resolve(__dirname, 'fixture/workspace-test'), testModulePath); + + await spawnPromise('yarn', [], { + cwd: testModulePath, + stdio: 'ignore' + }); + + const projectRootPath = await getProjectRootPath(path.join(testModulePath, 'workspace-test', 'child-workspace')); + + await rebuild({ + buildPath: path.resolve(testModulePath, 'child-workspace'), + electronVersion: '5.0.13', + arch: process.arch, + projectRootPath + }); + }); + + it('should have rebuilt top level prod dependencies', async () => { + await expectNativeModuleToBeRebuilt(testModulePath, 'ref-napi'); + }); + + it('should not have rebuilt top level devDependencies', async () => { + await expectNativeModuleToNotBeRebuilt(testModulePath, 'ffi-napi'); + }); + + after(async () => { + await fs.remove(testModulePath); + }); + }); +}); diff --git a/test/search-module.ts b/test/search-module.ts new file mode 100644 index 00000000..f9321ef4 --- /dev/null +++ b/test/search-module.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; + +import { getProjectRootPath } from '../src/search-module'; + +let baseDir: string; + +async function createTempDir(): Promise { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-rebuild-test-')); +} + +async function removeTempDir(): Promise { + await fs.remove(baseDir); +} + +describe('search-module', () => { + describe('getProjectRootPath', () => { + describe('multi-level workspace', () => { + for (const lockFile of ['yarn.lock', 'package-lock.json']) { + describe(lockFile, () => { + before(async () => { + await createTempDir(); + await fs.copy(path.resolve(__dirname, 'fixture', 'multi-level-workspace'), baseDir); + await fs.ensureFile(path.join(baseDir, lockFile)); + }); + + it('finds the folder with the lockfile', async () => { + const packageDir = path.join(baseDir, 'packages', 'bar'); + expect(await getProjectRootPath(packageDir)).to.equal(baseDir); + }); + + after(removeTempDir); + }); + } + }); + + describe('no workspace', () => { + before(createTempDir); + + it('returns the input directory if a lockfile cannot be found', async () => { + expect(await getProjectRootPath(baseDir)).to.equal(baseDir); + }); + + after(removeTempDir); + }); + }); +});