diff --git a/package.json b/package.json index 91531089c..4b5343611 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "types": "dist/index.d.ts", "devDependencies": { "@oclif/plugin-command-snapshot": "^5.2.3", + "@oclif/test": "^4.1.13", "@salesforce/dev-scripts": "^11.0.2", "@salesforce/plugin-release-management": "^5.7.0", "@salesforce/ts-sinon": "^1.4.30", diff --git a/src/hooks/prerun.ts b/src/hooks/prerun.ts index dd1ad308c..beb4fa8cf 100644 --- a/src/hooks/prerun.ts +++ b/src/hooks/prerun.ts @@ -7,9 +7,8 @@ import { type Hook } from '@oclif/core/hooks'; -// eslint-disable-next-line @typescript-eslint/require-await -const hook: Hook.Prerun = async function ({ Command, config }) { - if (process.argv.includes('--json')) return; +const hook: Hook.Prerun = async function ({ Command, config, argv }) { + if (argv.includes('--json') || process.argv.includes('--json')) return; const { plugin } = Command; if (!plugin) return; if (plugin.type === 'link') return; @@ -20,10 +19,31 @@ const hook: Hook.Prerun = async function ({ Command, config }) { const specifiedVersion = jitPlugins[plugin.name] ?? deps[plugin.name]; if (!specifiedVersion) return; - if (plugin.version !== specifiedVersion) { + // Simple semver comparison without external library + const parseVersion = (version: string): number[] => + version + .replace(/^[^\d]*/, '') + .split('.') + .map((n) => parseInt(n, 10) || 0); + + const isLessThan = (version1: string, version2: string): boolean => { + const v1 = parseVersion(version1); + const v2 = parseVersion(version2); + + for (let i = 0; i < Math.max(v1.length, v2.length); i++) { + const num1 = v1[i] || 0; + const num2 = v2[i] || 0; + + if (num1 < num2) return true; + if (num1 > num2) return false; + } + return false; + }; + + if (isLessThan(plugin.version, specifiedVersion)) { const { ux } = await import('@oclif/core/ux'); ux.warn( - `Plugin ${plugin.name} (${plugin.version}) differs from the version specified by ${config.bin} (${specifiedVersion})` + `Plugin ${plugin.name} (${plugin.version}) is older than the version specified by ${config.bin} (${specifiedVersion})` ); } }; diff --git a/test/hooks/prerun.test.ts b/test/hooks/prerun.test.ts new file mode 100644 index 000000000..7150d9f19 --- /dev/null +++ b/test/hooks/prerun.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect } from 'chai'; +import { runHook } from '@oclif/test'; +import { Config } from '@oclif/core/config'; + +function sanitize(stderr: string): string { + return stderr + .replaceAll('› ', ' ') + .replaceAll('» ', ' ') + .split('\n') + .map((s) => s.trim()) + .join(' '); +} + +describe('Prerun Hook', () => { + const WARNING_REGEX = + /Warning: Plugin ([^ ]+) \((\d+\.\d+\.\d+(?:-[^)]*)?)\) is older than the version specified by ([^ ]+) \((\d+\.\d+\.\d+(?:-[^)]*)?)\)/; + let config: Config; + + before(async () => { + config = await Config.load({ root: dirname(fileURLToPath(import.meta.url)) }); + }); + + it('should do nothing when --json is present', async () => { + const { stderr } = await runHook('prerun', { argv: ['--json'] }); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should do nothing if plugin type is "link"', async () => { + const command = { + type: 'link', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should do nothing if plugin is not JIT or core', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-foo', + version: '1.0.0', + }, + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should warn if plugin version is less than specified version', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '0.0.1', + }, + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }); + expect(sanitize(stderr)).to.match(WARNING_REGEX); + }); + + it('should not warn if plugin version is greater than specified version', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '10000.0.0', + }, + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should not warn if plugin version is equal to specified version', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '1.0.0', + }, + }; + + config.pjson.dependencies = { + ...config.pjson.dependencies, + '@salesforce/plugin-packaging': '1.0.0', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }, config); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should warn if plugin version is an older prerelease', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '1.0.0-beta.1', + }, + }; + + config.pjson.dependencies = { + ...config.pjson.dependencies, + '@salesforce/plugin-packaging': '1.0.0-beta.2', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }, config); + expect(sanitize(stderr)).to.match(WARNING_REGEX); + }); + + it('should not warn if plugin version is a newer prerelease', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '1.0.0-beta.2', + }, + }; + + config.pjson.dependencies = { + ...config.pjson.dependencies, + '@salesforce/plugin-packaging': '1.0.0-beta.1', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }, config); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should not warn if plugin version is a newer prerelease and specified version is an older stable release', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '1.0.0-beta.1', + }, + }; + + config.pjson.dependencies = { + ...config.pjson.dependencies, + '@salesforce/plugin-packaging': '1.0.0', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }, config); + expect(sanitize(stderr)).to.not.match(WARNING_REGEX); + }); + + it('should warn if plugin version is an older prerelease and specified version is a newer stable release', async () => { + const command = { + plugin: { + type: 'core', + name: '@salesforce/plugin-packaging', + version: '1.0.0-beta.1', + }, + }; + config.pjson.dependencies = { + ...config.pjson.dependencies, + '@salesforce/plugin-packaging': '1.0.1', + }; + const { stderr } = await runHook('prerun', { Command: command, argv: [] }, config); + expect(sanitize(stderr)).to.match(WARNING_REGEX); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1251d7aa5..712c12e06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1785,6 +1785,14 @@ strip-ansi "^7.1.0" wrap-ansi "^9.0.0" +"@oclif/test@^4.1.13": + version "4.1.13" + resolved "https://registry.yarnpkg.com/@oclif/test/-/test-4.1.13.tgz#b39b541f2edf45828aafb8d7df635516e0356b83" + integrity sha512-pulrTiJRhoAKizFf6y5WeHvM2JyoRiZKV0H8qqYEoE0UHDKqInNmfGJyp8Ip6lTVQeMv1U8YCAXOS/HiWPVWeg== + dependencies: + ansis "^3.17.0" + debug "^4.4.1" + "@octokit/auth-token@^5.0.0": version "5.1.2" resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.2.tgz#68a486714d7a7fd1df56cb9bc89a860a0de866de"