diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index cfe8d7d9..ed2cbb03 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,13 +10,12 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -/** - * Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") - * Intentionally permissive to match pre-release versions (v18.17.0-beta, v18.17.0-rc1) - * since localeCompare with numeric:true handles sorting correctly - */ +/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ const VERSION_DIR_PATTERN = /^v?\d+/; +/** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */ +const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + /** Result of finding Node.js executable */ export interface NodeFinderResult { /** Path to the Node.js executable */ @@ -65,11 +64,8 @@ function isExecutable(filePath: string): boolean { /** * Find Node.js executable from version manager directories (NVM, fnm) - * Uses semantic version sorting to prefer the latest version - * - * Note: Version sorting uses localeCompare with numeric:true which handles most cases - * correctly (e.g., v18.17.0 > v18.9.0) but may not perfectly sort pre-release versions - * (e.g., v20.0.0-beta vs v19.9.9). This is acceptable as we prefer the latest stable. + * Uses semantic version sorting to prefer the latest stable version + * Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback */ function findNodeFromVersionManager( basePath: string, @@ -78,13 +74,18 @@ function findNodeFromVersionManager( if (!fs.existsSync(basePath)) return null; try { - const versions = fs + const allVersions = fs .readdirSync(basePath) .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); - for (const version of versions) { + // Separate stable and pre-release versions, preferring stable + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + + // Try stable versions first, then fall back to pre-release + for (const version of [...stableVersions, ...preReleaseVersions]) { const nodePath = path.join(basePath, version, binSubpath); if (isExecutable(nodePath)) { return nodePath; diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 62976446..6956884b 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -4,6 +4,59 @@ import path from 'path'; import fs from 'fs'; describe('node-finder', () => { + describe('version sorting and pre-release filtering', () => { + // Test the PRE_RELEASE_PATTERN logic indirectly + const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + + it('should identify pre-release versions correctly', () => { + const preReleaseVersions = [ + 'v20.0.0-beta', + 'v18.17.0-rc1', + 'v19.0.0-alpha', + 'v21.0.0-nightly', + 'v20.0.0-canary', + 'v18.0.0-dev', + 'v17.0.0-pre', + ]; + + for (const version of preReleaseVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(true); + } + }); + + it('should not match stable versions as pre-release', () => { + const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0']; + + for (const version of stableVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(false); + } + }); + + it('should sort versions with numeric comparison', () => { + const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0']; + const sorted = [...versions].sort((a, b) => + b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }) + ); + + expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']); + }); + + it('should prefer stable over pre-release when filtering', () => { + const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1']; + + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + const prioritized = [...stableVersions, ...preReleaseVersions]; + + // Stable versions should come first + expect(prioritized[0]).toBe('v19.9.9'); + expect(prioritized[1]).toBe('v18.17.0'); + // Pre-release versions should come after + expect(prioritized[2]).toBe('v20.0.0-beta'); + expect(prioritized[3]).toBe('v21.0.0-rc1'); + }); + }); + describe('findNodeExecutable', () => { it("should return 'node' with fallback source when skipSearch is true", () => { const result = findNodeExecutable({ skipSearch: true });