Visitar URL original
fix(tools): respect .only in run-test runner across Node.js and Bun by danieljbruce · Pull Request #9563 · googleapis/google-cloud-node · GitHub
Skip to content

fix(tools): respect .only in run-test runner across Node.js and Bun - #9563

Draft
danieljbruce wants to merge 4 commits into
googleapis:mainfrom
danieljbruce:fix/respect-dot-only
Draft

danieljbruce wants to merge 4 commits into
googleapis:mainfrom
danieljbruce:fix/respect-dot-only

Conversation

@danieljbruce

Copy link
Copy Markdown
Contributor

Description

Updates bin/run-test.cjs so that exclusive Mocha tests (it.only, describe.only, context.only, suite.only, test.only) work out of the box and execute only the exclusive test(s) across both Node.js and Bun.

Impact

Previously, adding .only to a unit test caused Mocha to fail with Uncaught Error: `.only` is not supported in parallel mode whenever .mocharc.cjs (parallel: true) or --parallel was active, and running bun run test (without --bun or JS_RUNTIME=bun) fell back to Node.js parallel execution instead of Bun. In addition, passing an entire test directory when only a single file contained .only caused Mocha to load every unrelated test file in the suite. With this change, .only is automatically detected, parallel mode is disabled, and execution is narrowed to only the test file(s) containing .only.

Changes

  • Detect bun run invocations via npm_config_user_agent in bin/run-test.cjs so bun run test automatically runs under Bun unless JS_RUNTIME=node is explicitly set.
  • Scan target test files (stripping comments and string literals to avoid false positives) for .only( calls (it.only, describe.only, context.only, suite.only, test.only).
  • If a source .ts test file contains .only and is newer than the compiled output in build/, automatically run the package's compile script before resolving .only files.
  • When .only is present in one or more test files, set MOCHA_PARALLEL=false, strip --parallel / -p, pass --no-parallel, and narrow the positional test file arguments to only the file(s) containing .only.
  • Add unit tests in .github/scripts/tests/run-test.test.cjs covering comment/string stripping, argument narrowing, and end-to-end .only execution under both Node.js and Bun.

Testing

  • Ran .github/scripts/tests/run-test.test.cjs verifying .only detection and end-to-end execution under both Node.js and Bun.
  • Verified .only execution against package test suites with --config ../../.mocharc.cjs across pnpm test, bun run test, and bun --bun run test.

Alternatives

  • We considered removing bin/run-test.cjs or disabling parallel: true globally in .mocharc.cjs, but disabling parallelism globally would significantly slow down full unit test suites under Node.js, whereas dynamically disabling parallel mode and narrowing target files only when .only is detected preserves full-suite parallelism while making .only fast and seamless.
  • Not merging this PR would continue preventing developers from using .only to isolate tests when debugging under bin/run-test.cjs.

Fixes b/571064199

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for .only test execution in bin/run-test.cjs by detecting exclusive tests, narrowing down target files, and disabling parallel execution when .only is present. It also adds corresponding unit tests and improves package binary resolution. Feedback on these changes suggests dynamically detecting the active package manager instead of hardcoding npm when compiling stale source files, and wrapping the pnpm fallback resolution logic in a try...catch block to avoid masking the original resolution error.

Comment thread bin/run-test.cjs
Comment on lines +314 to +319
if (pkg.scripts && pkg.scripts.compile) {
const compileRes = spawnSync('npm', ['run', 'compile'], {
cwd,
stdio: 'inherit',
env: {...process.env, RUN_TEST_SKIP_COMPILE: 'true'},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When invoking the compilation script, it is better to use the active package manager (e.g., pnpm, bun, or yarn) that initiated the test run, rather than hardcoding npm. This ensures that workspace links, lockfiles, and speed optimizations of the active package manager are preserved.

    if (pkg.scripts && pkg.scripts.compile) {
      const userAgent = process.env.npm_config_user_agent || '';
      const pm = userAgent.startsWith('pnpm/')
        ? 'pnpm'
        : userAgent.startsWith('bun/')
        ? 'bun'
        : userAgent.startsWith('yarn/')
        ? 'yarn'
        : 'npm';
      const compileRes = spawnSync(pm, ['run', 'compile'], {
        cwd,
        stdio: 'inherit',
        env: {...process.env, RUN_TEST_SKIP_COMPILE: 'true'},
      });

Comment thread bin/run-test.cjs
Comment on lines +360 to +375
} catch (err) {
const pkgName = pkgBin.split('/')[0];
const pnpmDir = path.join(repoRoot, 'node_modules', '.pnpm');
if (fs.existsSync(pnpmDir)) {
const entry = fs
.readdirSync(pnpmDir)
.find(d => d.startsWith(`${pkgName}@`));
if (entry) {
const pnpmCandidate = path.join(pnpmDir, entry, 'node_modules', pkgBin);
if (fs.existsSync(pnpmCandidate)) {
return pnpmCandidate;
}
}
}
throw err;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If any error occurs during the pnpm fallback resolution (such as fs.readdirSync throwing due to permission issues or missing directories), it will mask the original require.resolve error. Wrapping the fallback logic in a try...catch block ensures that we gracefully fall back to throwing the original require.resolve error.

  } catch (err) {
    try {
      const pkgName = pkgBin.split('/')[0];
      const pnpmDir = path.join(repoRoot, 'node_modules', '.pnpm');
      if (fs.existsSync(pnpmDir)) {
        const entry = fs
          .readdirSync(pnpmDir)
          .find(d => d.startsWith(pkgName + '@'));
        if (entry) {
          const pnpmCandidate = path.join(pnpmDir, entry, 'node_modules', pkgBin);
          if (fs.existsSync(pnpmCandidate)) {
            return pnpmCandidate;
          }
        }
      }
    } catch {}
    throw err;
  }

@danieljbruce

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for running exclusive tests (using .only) in the test runner script bin/run-test.cjs, along with a comprehensive test suite in .github/scripts/tests/run-test.test.cjs. It parses Mocha arguments, detects .only calls while ignoring comments/strings, automatically compiles stale TypeScript source files, and disables parallel execution when running exclusive tests. The review feedback is highly constructive and identifies several critical areas for improvement: handling Windows platform compatibility and alternative package managers when spawning the compile script, using relative paths in getTestStem to prevent name collisions, avoiding call stack limits by replacing the spread operator with reduce for file arrays, filtering out parallel flags that contain values, and correctly resolving scoped packages in pnpm's virtual store.

Comment thread bin/run-test.cjs
Comment on lines +416 to +420
const compileRes = spawnSync('npm', ['run', 'compile'], {
cwd,
stdio: 'inherit',
env: {...process.env, RUN_TEST_SKIP_COMPILE: 'true'},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Windows, spawning shell commands like npm directly without shell: true or appending .cmd will throw an ENOENT error. Additionally, in monorepos using pnpm or yarn, running npm run compile can fail or bypass workspace configurations. We should detect the active package manager from process.env.npm_config_user_agent and append .cmd on Windows for non-Bun package managers.

      const userAgent = process.env.npm_config_user_agent || '';
      let pkgManager = 'npm';
      if (userAgent.startsWith('pnpm/')) {
        pkgManager = 'pnpm';
      } else if (userAgent.startsWith('yarn/')) {
        pkgManager = 'yarn';
      } else if (userAgent.startsWith('bun/')) {
        pkgManager = 'bun';
      }
      const cmd = process.platform === 'win32' && pkgManager !== 'bun' ? `${pkgManager}.cmd` : pkgManager;
      const compileRes = spawnSync(cmd, ['run', 'compile'], {
        cwd,
        stdio: 'inherit',
        env: {...process.env, RUN_TEST_SKIP_COMPILE: 'true'},
      });

Comment thread bin/run-test.cjs
Comment on lines +198 to +200
function getTestStem(filePath) {
return path.basename(filePath).replace(/\.[cm]?[jt]s$/, '');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Comparing only the base name of files can lead to false positives or negatives if there are same-named test files in different directories (e.g., test/unit/foo.ts vs test/integration/foo.ts). Using the relative path from the package root (with the build/ prefix stripped) is much more robust.

function getTestStem(filePath, cwd = process.cwd()) {
  const rel = path.relative(cwd, filePath).replace(/\\/g, '/');
  const cleanRel = rel.replace(/^build\/(?:cjs\/|esm\/)?/, '');
  return cleanRel.replace(/\.[cm]?[jt]s$/, '');
}

Comment thread bin/run-test.cjs
Comment on lines +393 to +394
const onlyStems = onlyFiles.map(getTestStem).sort().join('\0');
const sourceOnlyStems = sourceOnlyFiles.map(getTestStem).sort().join('\0');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the cwd parameter to getTestStem to ensure relative paths are resolved correctly against the package directory.

  const onlyStems = onlyFiles.map(f => getTestStem(f, cwd)).sort().join('\\0');
  const sourceOnlyStems = sourceOnlyFiles.map(f => getTestStem(f, cwd)).sort().join('\\0');

Comment thread bin/run-test.cjs
Comment on lines +395 to +402
const maxSourceOnlyMtime =
sourceOnlyFiles.length > 0
? Math.max(...sourceOnlyFiles.map(f => fs.statSync(f).mtimeMs))
: 0;
const minTargetMtime =
onlyFiles.length > 0
? Math.min(...onlyFiles.map(f => fs.statSync(f).mtimeMs))
: 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the spread operator (...) on arrays of dynamic/unknown length (like lists of test files) can cause a "Maximum call stack size exceeded" error if the array is very large. Using Array.prototype.reduce is safer and more robust.

  const maxSourceOnlyMtime = sourceOnlyFiles.reduce(
    (max, f) => Math.max(max, fs.statSync(f).mtimeMs),
    0,
  );
  const minTargetMtime = onlyFiles.reduce(
    (min, f) => Math.min(min, fs.statSync(f).mtimeMs),
    Infinity,
  );

Comment thread bin/run-test.cjs
Comment on lines +438 to +440
const filteredOptions = optionArgs.filter(
a => a !== '--parallel' && a !== '-p',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Users might pass parallel flags with values (e.g., --parallel=true or -p=true). We should filter these out as well to ensure parallel mode is completely disabled when .only is detected.

  const filteredOptions = optionArgs.filter(
    a => a !== '--parallel' && a !== '-p' && !a.startsWith('--parallel=') && !a.startsWith('-p='),
  );

Comment thread bin/run-test.cjs
Comment on lines +462 to +467
const pkgName = pkgBin.split('/')[0];
const pnpmDir = path.join(repoRoot, 'node_modules', '.pnpm');
if (fs.existsSync(pnpmDir)) {
const entry = fs
.readdirSync(pnpmDir)
.find(d => d.startsWith(`${pkgName}@`));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In pnpm, scoped packages are stored in .pnpm with a folder name format of @scope+name@version. The current logic only splits by / and takes the first part, which would be @scope, failing to match the actual folder name. We should handle scoped packages by joining the scope and name with +.

    let pkgName = pkgBin.split('/')[0];
    if (pkgName.startsWith('@') && pkgBin.split('/').length > 1) {
      pkgName = `${pkgName}+${pkgBin.split('/')[1]}`;
    }
    const pnpmDir = path.join(repoRoot, 'node_modules', '.pnpm');
    if (fs.existsSync(pnpmDir)) {
      const entry = fs
        .readdirSync(pnpmDir)
        .find(d => d.startsWith(`${pkgName}@`));

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant