From add161560701ac7fb2bc372ddefd9317146fbae8 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Mon, 7 Sep 2026 17:38:56 +0200 Subject: [PATCH] refactor(support)!: replace glob package with native node:fs glob (#22735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: @appium/support's fs.glob option shape changed from the glob package's GlobOptions to a smaller, @appium/support-owned GlobOptions interface. Supported fields: cwd, withFileTypes, absolute, lazy. Options like nodir/ignore/nocase are no longer supported directly — callers needing directory filtering can pass withFileTypes: true and filter on .isFile()/.isDirectory(); callers needing path exclusion can filter the returned array by pattern. --- package-lock.json | 8 ++- .../appium/lib/extension/manifest/manifest.ts | 54 +++++++++++++-- .../appium/test/unit/appiumdriver.spec.ts | 3 +- .../test/unit/extension/driver-config.spec.ts | 3 +- .../unit/extension/extension-command.spec.ts | 3 +- .../unit/extension/extension-config.spec.ts | 8 +-- .../test/unit/extension/manifest.spec.ts | 25 ++++++- packages/appium/test/unit/extension/mocks.ts | 6 +- .../unit/extension/package-changed.spec.ts | 3 +- .../test/unit/extension/plugin-config.spec.ts | 3 +- .../appium/test/unit/schema/schema.spec.ts | 3 +- .../base-driver/lib/basedriver/helpers.ts | 6 +- packages/storage-plugin/lib/storage.ts | 28 ++++---- packages/support/lib/fs.ts | 69 +++++++++++++++++-- packages/support/package.json | 1 - packages/support/test/unit/fs.spec.ts | 8 +++ scripts/sync-monorepo-packages.mjs | 5 +- 17 files changed, 190 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 614524d31..d296d382c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9550,6 +9550,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -9578,6 +9579,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -9587,6 +9589,7 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9599,6 +9602,7 @@ "version": "11.3.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9608,6 +9612,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -9623,6 +9628,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -9632,6 +9638,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -19642,7 +19649,6 @@ "archiver": "8.0.0", "asyncbox": "6.4.2", "axios": "1.19.0", - "glob": "13.0.6", "klaw": "4.1.0", "lockfile": "1.0.4", "mime-types": "3.0.2", diff --git a/packages/appium/lib/extension/manifest/manifest.ts b/packages/appium/lib/extension/manifest/manifest.ts index 645d637f4..aee7cec2a 100644 --- a/packages/appium/lib/extension/manifest/manifest.ts +++ b/packages/appium/lib/extension/manifest/manifest.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import {fs, util} from '@appium/support'; import type {DriverType, ExtensionType, PluginType} from '@appium/types'; import type {ExtManifest, ExtPackageJson, ExtRecord, InternalMetadata, ManifestData} from 'appium/types/index.js'; +import {asyncmap} from 'asyncbox'; import * as YAML from 'yaml'; import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../../constants.js'; @@ -16,6 +17,8 @@ import {INSTALL_TYPE_DEV, INSTALL_TYPE_NPM} from './install-types.js'; import {migrate} from './migrations.js'; import {manifestValidator} from './validator.js'; +const MAX_CONCURRENT_FS_TASKS = 10; + const CONFIG_DATA_DRIVER_KEY = `${DRIVER_TYPE}s` as const; const CONFIG_DATA_PLUGIN_KEY = `${PLUGIN_TYPE}s` as const; @@ -228,10 +231,7 @@ export class Manifest { const queue: Promise[] = [onMatch(path.join(this.#appiumHome, 'package.json'), true)]; - const filepaths = await fs.glob('node_modules/{*,@*/*}/package.json', { - cwd: this.#appiumHome, - absolute: true, - }); + const filepaths = await findNodeModulesPackageJsons(path.join(this.#appiumHome, 'node_modules')); for (const filepath of filepaths) { queue.push(onMatch(filepath)); } @@ -356,6 +356,52 @@ export class Manifest { } } +/** + * Finds `package.json` paths for packages directly under `node_modules` (and one level deeper for + * scoped `@foo/bar` packages). Unlike `fs.glob`, this follows symlinks, which is how `npm install + * ` (and `npm link`) install packages. + */ +async function findNodeModulesPackageJsons(nodeModulesDir: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(nodeModulesDir); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return []; + } + throw err; + } + + const pkgJsonPathsByEntry = await asyncmap( + entries.filter((entry) => !entry.startsWith('.')), + async (entry): Promise => { + const entryPath = path.join(nodeModulesDir, entry); + if (!entry.startsWith('@')) { + const pkgJsonPath = path.join(entryPath, 'package.json'); + return (await fs.exists(pkgJsonPath)) ? [pkgJsonPath] : []; + } + + let scopedEntries: string[]; + try { + scopedEntries = await fs.readdir(entryPath); + } catch { + return []; + } + const scopedPkgJsonPaths = await asyncmap( + scopedEntries.filter((scopedEntry) => !scopedEntry.startsWith('.')), + async (scopedEntry) => { + const pkgJsonPath = path.join(entryPath, scopedEntry, 'package.json'); + return (await fs.exists(pkgJsonPath)) ? pkgJsonPath : null; + }, + {concurrency: MAX_CONCURRENT_FS_TASKS}, + ); + return scopedPkgJsonPaths.filter((pkgJsonPath) => pkgJsonPath !== null); + }, + {concurrency: MAX_CONCURRENT_FS_TASKS}, + ); + return pkgJsonPathsByEntry.flat(); +} + function isExtension(value: unknown): value is ExtPackageJson { return ( util.isPlainObject(value) && diff --git a/packages/appium/test/unit/appiumdriver.spec.ts b/packages/appium/test/unit/appiumdriver.spec.ts index 63024100b..7bd7c9101 100644 --- a/packages/appium/test/unit/appiumdriver.spec.ts +++ b/packages/appium/test/unit/appiumdriver.spec.ts @@ -80,7 +80,8 @@ describe('AppiumDriver', function () { MockConfig.updateBuildInfo.resetBehavior(); MockConfig.updateBuildInfo.resolves(); - ({AppiumDriver} = await import(`../../lib/appium.js?t=${importCounter++}`)); + const mod = await import(`../../lib/appium.js?t=${importCounter++}`); + ({AppiumDriver} = mod); }); afterEach(function () { diff --git a/packages/appium/test/unit/extension/driver-config.spec.ts b/packages/appium/test/unit/extension/driver-config.spec.ts index ec7d0c37b..d7f0dd193 100644 --- a/packages/appium/test/unit/extension/driver-config.spec.ts +++ b/packages/appium/test/unit/extension/driver-config.spec.ts @@ -67,7 +67,8 @@ describe('DriverConfig', function () { manifest = Manifest.getInstance('/somewhere/'); resetMockDefaults(mocks); mocks.MockAppiumSupport.fs.readFile.resolves(yamlFixture); - ({DriverConfig} = await import(`../../../lib/extension/driver-config.js?t=${importCounter++}`)); + const mod = await import(`../../../lib/extension/driver-config.js?t=${importCounter++}`); + ({DriverConfig} = mod); resetSchema(); }); diff --git a/packages/appium/test/unit/extension/extension-command.spec.ts b/packages/appium/test/unit/extension/extension-command.spec.ts index bf540bc18..3ac4ff08d 100644 --- a/packages/appium/test/unit/extension/extension-command.spec.ts +++ b/packages/appium/test/unit/extension/extension-command.spec.ts @@ -130,7 +130,8 @@ describe('ExtensionCommand', function () { debug: sandbox.stub(), } as unknown as AppiumLogger; - ({injectAppiumSymlinks} = await import(`../../../lib/cli/extension-command.js?t=${importCounter++}`)); + const mod = await import(`../../../lib/cli/extension-command.js?t=${importCounter++}`); + ({injectAppiumSymlinks} = mod); }); afterEach(function () { diff --git a/packages/appium/test/unit/extension/extension-config.spec.ts b/packages/appium/test/unit/extension/extension-config.spec.ts index fcecc46fb..9b00ebfea 100644 --- a/packages/appium/test/unit/extension/extension-config.spec.ts +++ b/packages/appium/test/unit/extension/extension-config.spec.ts @@ -40,10 +40,10 @@ describe('ExtensionConfig', function () { resetMockDefaults(mocks); MockAppiumSupport = mocks.MockAppiumSupport; MockNpm = mocks.MockNpm; - ({ExtensionConfig, resolveEsmEntryPoint} = await import( - `../../../lib/extension/extension-config.js?t=${importCounter++}` - )); - ({Manifest} = await import(`../../../lib/extension/manifest/manifest.js?t=${importCounter++}`)); + const extensionConfigMod = await import(`../../../lib/extension/extension-config.js?t=${importCounter++}`); + ({ExtensionConfig, resolveEsmEntryPoint} = extensionConfigMod); + const manifestMod = await import(`../../../lib/extension/manifest/manifest.js?t=${importCounter++}`); + ({Manifest} = manifestMod); }); afterEach(function () { diff --git a/packages/appium/test/unit/extension/manifest.spec.ts b/packages/appium/test/unit/extension/manifest.spec.ts index d0e20138b..8c4f41be7 100644 --- a/packages/appium/test/unit/extension/manifest.spec.ts +++ b/packages/appium/test/unit/extension/manifest.spec.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import {promises as fs} from 'node:fs'; +import path from 'node:path'; import {describe, it, beforeEach, afterEach, before, after, mock} from 'node:test'; import type {DriverType, PluginType} from '@appium/types'; @@ -42,7 +43,8 @@ describe('Manifest', function () { resetMockDefaults(mocks); migrateStub.resolves(); MockAppiumSupport.fs.readFile.resolves(yamlFixture); - ({Manifest} = await import(`../../../lib/extension/manifest/manifest.js?t=${importCounter++}`)); + const mod = await import(`../../../lib/extension/manifest/manifest.js?t=${importCounter++}`); + ({Manifest} = mod); }); describe('class method', function () { @@ -526,12 +528,31 @@ describe('Manifest', function () { describe('when the underlying implementation emits "error"', function () { beforeEach(function () { - MockAppiumSupport.fs.glob.rejects(new Error('bogus')); + MockAppiumSupport.fs.readdir.rejects(new Error('bogus')); }); it('should reject', async function () { await assert.rejects(manifest.syncWithInstalledExtensions(), /bogus/); }); }); + + describe('when node_modules contains dot-prefixed entries', function () { + const nodeModulesDir = path.join('/some/path', 'node_modules'); + const scopedDir = path.join(nodeModulesDir, '@scope'); + + beforeEach(function () { + MockAppiumSupport.fs.readdir.withArgs(nodeModulesDir).resolves(['.hidden-driver', 'normal-driver', '@scope']); + MockAppiumSupport.fs.readdir.withArgs(scopedDir).resolves(['.hidden-scoped', 'visible-scoped']); + }); + + it('should not probe dot-prefixed package directories', async function () { + await manifest.syncWithInstalledExtensions(); + const probedPaths = MockAppiumSupport.fs.exists.getCalls().map((call: any) => call.args[0] as string); + assert.ok(!probedPaths.some((p) => p.includes('.hidden-driver'))); + assert.ok(!probedPaths.some((p) => p.includes('.hidden-scoped'))); + assert.ok(probedPaths.some((p) => p.includes('normal-driver'))); + assert.ok(probedPaths.some((p) => p.includes('visible-scoped'))); + }); + }); }); describe('hasDriver()', function () { diff --git a/packages/appium/test/unit/extension/mocks.ts b/packages/appium/test/unit/extension/mocks.ts index 2484edb25..cbd50baba 100644 --- a/packages/appium/test/unit/extension/mocks.ts +++ b/packages/appium/test/unit/extension/mocks.ts @@ -16,7 +16,7 @@ export interface MockAppiumSupportFs { readFile: SinonStub; writeFile: SinonStub; walk: SinonStub; - glob: SinonStub; + readdir: SinonStub; mkdirp: SinonStub; exists: SinonStub; realpath: SinonStub; @@ -86,7 +86,7 @@ export function initMocks(sandbox = createSandbox()): InitMocksResult { walk: sandbox.stub().returns({ [Symbol.asyncIterator]: sandbox.stub().returns({next: sandbox.stub().resolves({done: true})}), }), - glob: sandbox.stub().resolves([]), + readdir: sandbox.stub().resolves([]), mkdirp: sandbox.stub().resolves(), exists: sandbox.stub().resolves(true), realpath: sandbox.stub().callsFake(async (p: string) => p), @@ -156,7 +156,7 @@ export function resetMockDefaults(mocks: InitMocksResult): void { MockAppiumSupport.fs.walk.returns({ [Symbol.asyncIterator]: mocks.sandbox.stub().returns({next: mocks.sandbox.stub().resolves({done: true})}), }); - MockAppiumSupport.fs.glob.resolves([]); + MockAppiumSupport.fs.readdir.resolves([]); MockAppiumSupport.fs.mkdirp.resolves(); MockAppiumSupport.fs.exists.resolves(true); MockAppiumSupport.env.resolveAppiumHome.resolves('/some/path'); diff --git a/packages/appium/test/unit/extension/package-changed.spec.ts b/packages/appium/test/unit/extension/package-changed.spec.ts index b78b06bf5..fdbb6f7df 100644 --- a/packages/appium/test/unit/extension/package-changed.spec.ts +++ b/packages/appium/test/unit/extension/package-changed.spec.ts @@ -44,7 +44,8 @@ describe('package-changed', function () { beforeEach(async function () { resetMockDefaults(mocks); - ({packageDidChange} = await import(`../../../lib/utils/package-changed.js?t=${importCounter++}`)); + const mod = await import(`../../../lib/utils/package-changed.js?t=${importCounter++}`); + ({packageDidChange} = mod); }); describe('packageDidChange()', function () { diff --git a/packages/appium/test/unit/extension/plugin-config.spec.ts b/packages/appium/test/unit/extension/plugin-config.spec.ts index 23ad8abd1..5508fcc75 100644 --- a/packages/appium/test/unit/extension/plugin-config.spec.ts +++ b/packages/appium/test/unit/extension/plugin-config.spec.ts @@ -56,7 +56,8 @@ describe('PluginConfig', function () { manifest = Manifest.getInstance('/somewhere/'); resetMockDefaults(mocks); mocks.MockAppiumSupport.fs.readFile.resolves(yamlFixture); - ({PluginConfig} = await import(`../../../lib/extension/plugin-config.js?t=${importCounter++}`)); + const mod = await import(`../../../lib/extension/plugin-config.js?t=${importCounter++}`); + ({PluginConfig} = mod); resetSchema(); }); diff --git a/packages/appium/test/unit/schema/schema.spec.ts b/packages/appium/test/unit/schema/schema.spec.ts index 70472f098..2beb321b5 100644 --- a/packages/appium/test/unit/schema/schema.spec.ts +++ b/packages/appium/test/unit/schema/schema.spec.ts @@ -30,6 +30,7 @@ describe('schema', function () { // fresh (cache-busted) each test isolates that singleton per test, same as the explicit // `resetSchema()` call below does for the parts reachable through its public API. beforeEach(async function () { + const mod = await import(`../../../lib/schema/schema.js?t=${importCounter++}`); ({ SchemaFinalizationError, SchemaUnknownSchemaError, @@ -43,7 +44,7 @@ describe('schema', function () { getDefaultsForSchema, flattenSchema, validate, - } = await import(`../../../lib/schema/schema.js?t=${importCounter++}`)); + } = mod); resetSchema(); }); diff --git a/packages/base-driver/lib/basedriver/helpers.ts b/packages/base-driver/lib/basedriver/helpers.ts index 0a6f4cf7a..be7449245 100644 --- a/packages/base-driver/lib/basedriver/helpers.ts +++ b/packages/base-driver/lib/basedriver/helpers.ts @@ -527,7 +527,11 @@ function verifyAppExtension(app: string, supportedAppExtensions: string[]): stri } async function calculateFolderIntegrity(folderPath: string): Promise { - return (await fs.glob('**/*', {cwd: folderPath})).length; + let count = 0; + for await (const _entry of fs.glob('**/*', {cwd: folderPath, lazy: true})) { + count++; + } + return count; } async function calculateFileIntegrity(filePath: string): Promise { diff --git a/packages/storage-plugin/lib/storage.ts b/packages/storage-plugin/lib/storage.ts index 796a9dff5..a8b90658f 100644 --- a/packages/storage-plugin/lib/storage.ts +++ b/packages/storage-plugin/lib/storage.ts @@ -1,5 +1,5 @@ import {createHash} from 'node:crypto'; -import nativeFs from 'node:fs'; +import nativeFs, {type Dirent} from 'node:fs'; import path from 'node:path'; import type Stream from 'node:stream'; @@ -7,7 +7,6 @@ import {fs, timing, util} from '@appium/support'; import type {AppiumLogger} from '@appium/types'; import AsyncLock from 'async-lock'; import {asyncmap} from 'asyncbox'; -import type {Path} from 'path-scurry'; import type WebSocket from 'ws'; import type {ItemOptions, StorageItem} from './types.js'; @@ -32,17 +31,17 @@ export class Storage { } async list(): Promise { - const items = (await this._listFiles()).filter((p) => !p.fullpath().endsWith(TMP_EXT)); + const items = (await this._listFiles()).filter((item) => !item.name.endsWith(TMP_EXT)); if (util.isEmpty(items)) { return []; } - const stats = await asyncmap(items, (item) => fs.stat(item.fullpath()), { + const stats = await asyncmap(items, (item) => fs.stat(this._toFullPath(item)), { concurrency: MAX_TASKS, }); return items.map((item, index) => ({ - name: path.basename(item.fullpath()), - path: item.fullpath(), + name: item.name, + path: this._toFullPath(item), size: stats[index].size, })); } @@ -82,8 +81,8 @@ export class Storage { } const files = (await this._listFiles()) - .map((p) => p.fullpath()) - .filter((fullPath) => !this._shouldPreserveFiles || path.basename(fullPath).toLowerCase().endsWith(TMP_EXT)); + .filter((item) => !this._shouldPreserveFiles || item.name.toLowerCase().endsWith(TMP_EXT)) + .map((item) => this._toFullPath(item)); if (util.isEmpty(files)) { return; } @@ -126,12 +125,13 @@ export class Storage { } } - private async _listFiles(): Promise { - const paths = (await fs.glob('*', { - cwd: this._root, - withFileTypes: true, - })) as unknown as Path[]; - return paths.filter((item) => item.isFile()); + private async _listFiles(): Promise { + const items = await fs.glob('*', {cwd: this._root, withFileTypes: true}); + return items.filter((item) => item.isFile()); + } + + private _toFullPath(item: Dirent): string { + return path.join(item.parentPath, item.name); } private async _addFromStream(opts: ItemOptions, source: Stream): Promise { diff --git a/packages/support/lib/fs.ts b/packages/support/lib/fs.ts index 583bbea10..188a70cfa 100644 --- a/packages/support/lib/fs.ts +++ b/packages/support/lib/fs.ts @@ -5,6 +5,7 @@ import { type CopyOptions, createReadStream, createWriteStream, + type Dirent, type MakeDirectoryOptions, open, type PathLike, @@ -17,8 +18,6 @@ import { import path from 'node:path'; import {promisify} from 'node:util'; -import {glob} from 'glob'; -import type {GlobOptions} from 'glob'; import klaw from 'klaw'; import type {Walker} from 'klaw'; import sanitize from 'sanitize-filename'; @@ -64,6 +63,43 @@ export interface MvOptions { */ export type WalkDirCallback = (itemPath: string, isDirectory: boolean) => boolean | void | Promise; +/** Options for {@linkcode fs.glob}. Deliberately narrower than `node:fs`'s own `GlobOptions` so callers are insulated from its shape. */ +export interface GlobOptions { + /** + * Current working directory to resolve `pattern` and results against. + * @default process.cwd() + */ + cwd?: string; + /** Yield `Dirent`s instead of path strings. */ + withFileTypes?: boolean; + /** Yield absolute paths instead of paths relative to `cwd`. Ignored when `withFileTypes` is set (a `Dirent`'s location is already recoverable via `parentPath`). */ + absolute?: boolean; + /** Return an async generator that yields matches lazily instead of resolving to an array of all of them. */ + lazy?: boolean; +} + +/** + * Overloaded call signature for {@linkcode fs.glob}, narrowing its return type based on + * `withFileTypes`/`lazy`. When either flag is a non-literal `boolean` (e.g. a `GlobOptions` + * variable), the return type widens to a union instead of picking a single (possibly wrong) shape. + */ +export interface GlobFn { + ( + pattern: string | readonly string[], + options: GlobOptions & {withFileTypes: true; lazy: true}, + ): AsyncGenerator; + (pattern: string | readonly string[], options: GlobOptions & {withFileTypes: true; lazy?: false}): Promise; + ( + pattern: string | readonly string[], + options: GlobOptions & {withFileTypes: true}, + ): Promise | AsyncGenerator; + (pattern: string | readonly string[], options: GlobOptions & {lazy: true}): AsyncGenerator; + ( + pattern: string | readonly string[], + options?: GlobOptions, + ): Promise | Promise | AsyncGenerator | AsyncGenerator; +} + /** * Maps {@link CopyFileOptions} (including legacy `ncp` fields) to `fs.cp` options. */ @@ -237,12 +273,31 @@ export const fs = { which, /** - * Given a glob pattern, resolve with list of files matching that pattern. - * @see https://github.com/isaacs/node-glob + * Given a glob pattern, resolves to an array of matching paths (or `Dirent`s if `withFileTypes` is set). + * Pass `lazy: true` to get an async generator that yields matches one at a time instead. + * + * Unlike the `glob` npm package, this does NOT follow symlinks when expanding wildcard path + * segments. Prefer a manual `readdir`-based walk over a glob pattern for callers that must + * traverse through symlinks. */ - glob(pattern: string, options?: GlobOptions): Promise { - return Promise.resolve((options ? glob(pattern, options) : glob(pattern)) as Promise); - }, + glob: ((pattern: string | readonly string[], options: GlobOptions = {}) => { + const {cwd, withFileTypes, absolute, lazy} = options; + async function* generate(): AsyncGenerator { + for await (const entry of fsPromises.glob(pattern, {cwd, withFileTypes})) { + yield absolute && !withFileTypes ? path.resolve(cwd ?? process.cwd(), entry as string) : entry; + } + } + if (lazy) { + return generate(); + } + return (async () => { + const items: (string | Dirent)[] = []; + for await (const item of generate()) { + items.push(item); + } + return items; + })(); + }) as GlobFn, /** Sanitize a filename. @see https://github.com/parshap/node-sanitize-filename */ sanitizeName: sanitize, diff --git a/packages/support/package.json b/packages/support/package.json index 4dac13515..633d24be4 100644 --- a/packages/support/package.json +++ b/packages/support/package.json @@ -57,7 +57,6 @@ "archiver": "8.0.0", "asyncbox": "6.4.2", "axios": "1.19.0", - "glob": "13.0.6", "klaw": "4.1.0", "lockfile": "1.0.4", "mime-types": "3.0.2", diff --git a/packages/support/test/unit/fs.spec.ts b/packages/support/test/unit/fs.spec.ts index a0ea73950..e1483c353 100644 --- a/packages/support/test/unit/fs.spec.ts +++ b/packages/support/test/unit/fs.spec.ts @@ -169,6 +169,14 @@ describe('fs', {timeout: TEST_TIMEOUT}, function () { assert.ok(Array.isArray(tests)); assert.ok(tests.length > 2); }); + it('glob() with lazy', async function () { + const glob = '*.spec.js'; + const tests: string[] = []; + for await (const test of fs.glob(glob, {cwd: import.meta.dirname, lazy: true})) { + tests.push(test); + } + assert.ok(tests.length > 2); + }); describe('walkDir()', function () { it('walkDir recursive', async function () { diff --git a/scripts/sync-monorepo-packages.mjs b/scripts/sync-monorepo-packages.mjs index 2215d6393..0bf27048e 100644 --- a/scripts/sync-monorepo-packages.mjs +++ b/scripts/sync-monorepo-packages.mjs @@ -44,9 +44,8 @@ async function writeJson(filepath, data) { * @returns {Promise} An array of package directory paths. */ async function getPackageDirs() { - return (await fs.glob('*/package.json', {cwd: ROOT_PACKAGES_DIR, absolute: true})) - .map((pkgJsonPath) => path.dirname(pkgJsonPath)) - .sort(); + const pkgJsonPaths = await fs.glob('*/package.json', {cwd: ROOT_PACKAGES_DIR, absolute: true}); + return pkgJsonPaths.map((pkgJsonPath) => path.dirname(pkgJsonPath)).sort(); } /**