diff --git a/workspaces/libnpmexec/lib/index.js b/workspaces/libnpmexec/lib/index.js index 35452f796246b..4d7c126654689 100644 --- a/workspaces/libnpmexec/lib/index.js +++ b/workspaces/libnpmexec/lib/index.js @@ -19,8 +19,6 @@ const runScript = require('./run-script.js') const isWindows = require('./is-windows.js') const withLock = require('./with-lock.js') -const binPaths = [] - // when checking the local tree we look up manifests, cache those results by // spec.raw so we don't have to fetch again when we check npxCache const manifests = new Map() @@ -113,6 +111,8 @@ const exec = async (opts) => { ...flatOptions } = opts + const binPaths = [] + let pkgPaths = opts.pkgPath if (typeof pkgPaths === 'string') { pkgPaths = [pkgPaths] @@ -196,7 +196,11 @@ const exec = async (opts) => { let commandManifest await Promise.all(packages.map(async (pkg, i) => { const spec = npa(pkg, path) - const { manifest, node } = await missingFromTree({ spec, tree: localTree, flatOptions }) + const { manifest, node } = await missingFromTree({ + spec, + tree: localTree, + flatOptions, + }) if (manifest) { // Package does not exist in the local tree needInstall.push({ spec, manifest }) diff --git a/workspaces/libnpmexec/test/local.js b/workspaces/libnpmexec/test/local.js index 2423440c9d82c..46700ea535f81 100644 --- a/workspaces/libnpmexec/test/local.js +++ b/workspaces/libnpmexec/test/local.js @@ -469,3 +469,66 @@ for (const allowDirectory of ['none', 'root']) { }) }) } + +t.test('npm exec sequential workspace runs with same-named local bins', async t => { + t.plan(2) + const { path, readOutput, rmOutput, registry } = setup(t, { + testdir: { + packages: { + a: { + 'package.json': { + name: 'workspace-a', + version: '1.0.0', + bin: { 'shared-bin': 'cli.js' }, + }, + 'cli.js': { key: 'shared-bin', value: 'A' }, + }, + b: { + 'package.json': { + name: 'workspace-b', + version: '1.0.0', + bin: { 'shared-bin': 'cli.js' }, + }, + 'cli.js': { key: 'shared-bin', value: 'B' }, + }, + }, + }, + }) + + // We mock the module exactly once. This simulates two sequential calls + // hitting the same module instance, allowing us to prove that state + // (like binPaths) is correctly cleared between runs and NOT preserved. + const libnpmexec = t.mock('../lib/index.js') + + const baseOpts = { + path, + runPath: path, + npxCache: resolve(path, 'npxCache'), + registry: registry.origin + '/', + localBin: resolve(path, 'node_modules', '.bin'), + call: '', + scriptShell: 'sh', + yes: true, // skip interactive prompt for npxCache install + } + + // Workspace A + await libnpmexec({ + ...baseOpts, + pkgPath: resolve(path, 'packages/a'), + args: ['shared-bin'], + }) + + const outputA = await readOutput('shared-bin') + t.equal(outputA.value, 'A', 'should run workspace A bin') + await rmOutput('shared-bin') + + // Workspace B + await libnpmexec({ + ...baseOpts, + pkgPath: resolve(path, 'packages/b'), + args: ['shared-bin'], + }) + + const outputB = await readOutput('shared-bin') + t.equal(outputB.value, 'B', 'should run workspace B bin, not cached workspace A bin') +})