From 136ddf675a026798f446c42f50941b5229112f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20W=C3=B3jcik?= Date: Fri, 20 Jun 2025 01:09:05 +0200 Subject: [PATCH] Updates ci-cd commands to set node version based on SPFx project version. Closes #6717 --- .../spfx/commands/SpfxCompatibilityMatrix.ts | 611 +++++++++++++++++ .../spfx/commands/project/DeployWorkflow.ts | 4 +- .../project-azuredevops-pipeline-add.spec.ts | 107 ++- .../project-azuredevops-pipeline-add.ts | 27 +- .../project-github-workflow-add.spec.ts | 110 +++- .../project/project-github-workflow-add.ts | 31 +- src/m365/spfx/commands/spfx-doctor.ts | 622 +----------------- src/utils/spfx.spec.ts | 39 +- src/utils/spfx.ts | 58 +- 9 files changed, 979 insertions(+), 630 deletions(-) create mode 100644 src/m365/spfx/commands/SpfxCompatibilityMatrix.ts diff --git a/src/m365/spfx/commands/SpfxCompatibilityMatrix.ts b/src/m365/spfx/commands/SpfxCompatibilityMatrix.ts new file mode 100644 index 00000000000..9296fd361db --- /dev/null +++ b/src/m365/spfx/commands/SpfxCompatibilityMatrix.ts @@ -0,0 +1,611 @@ +export interface VersionCheck { + /** + * Required version range in semver + */ + range: string; + /** + * What to do to fix it if the required range isn't met + */ + fix: string; +} + +/** + * Versions of SharePoint that support SharePoint Framework + */ +export enum SharePointVersion { + SP2016 = 1 << 0, + SP2019 = 1 << 1, + SPO = 1 << 2, + All = ~(~0 << 3) +} + +export interface SpfxVersionPrerequisites { + gulpCli?: VersionCheck; + node: VersionCheck; + sp: SharePointVersion; + yo: VersionCheck; +} + +export const versions: { [version: string]: SpfxVersionPrerequisites } = { + '1.0.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6', + fix: 'Install Node.js v6' + }, + sp: SharePointVersion.All, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.1.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6', + fix: 'Install Node.js v6' + }, + sp: SharePointVersion.All, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.2.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6', + fix: 'Install Node.js v6' + }, + sp: SharePointVersion.SP2019 | SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.4.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6', + fix: 'Install Node.js v6' + }, + sp: SharePointVersion.SP2019 | SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.4.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6 || ^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SP2019 | SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.5.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6 || ^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.5.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6 || ^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.6.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^6 || ^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.7.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.7.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.8.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.8.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8', + fix: 'Install Node.js v8' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.8.2': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8 || ^10', + fix: 'Install Node.js v10' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.9.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^8 || ^10', + fix: 'Install Node.js v10' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.9.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^10', + fix: 'Install Node.js v10' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.10.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^10', + fix: 'Install Node.js v10' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.11.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^10', + fix: 'Install Node.js v10' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.12.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12', + fix: 'Install Node.js v12' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.12.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12 || ^14', + fix: 'Install Node.js v12 or v14' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^3', + fix: 'npm i -g yo@3' + } + }, + '1.13.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12 || ^14', + fix: 'Install Node.js v12 or v14' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.13.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12 || ^14', + fix: 'Install Node.js v12 or v14' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.14.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12 || ^14', + fix: 'Install Node.js v12 or v14' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.15.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12.13 || ^14.15 || ^16.13', + fix: 'Install Node.js v12.13, v14.15, v16.13 or higher' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.15.2': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '^12.13 || ^14.15 || ^16.13', + fix: 'Install Node.js v12.13, v14.15, v16.13 or higher' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.16.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.16.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.17.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.17.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.17.2': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.17.3': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.17.4': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.18.0': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.18.1': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4', + fix: 'npm i -g yo@4' + } + }, + '1.18.2': { + gulpCli: { + range: '^1 || ^2', + fix: 'npm i -g gulp-cli@2' + }, + node: { + range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', + fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + }, + '1.19.0': { + gulpCli: { + range: '^1 || ^2 || ^3', + fix: 'npm i -g gulp-cli@3' + }, + node: { + range: '>=18.17.1 <19.0.0', + fix: 'Install Node.js >=18.17.1 <19.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + }, + '1.20.0': { + gulpCli: { + range: '^1 || ^2 || ^3', + fix: 'npm i -g gulp-cli@3' + }, + node: { + range: '>=18.17.1 <19.0.0', + fix: 'Install Node.js >=18.17.1 <19.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + }, + '1.21.0': { + gulpCli: { + range: '^1 || ^2 || ^3', + fix: 'npm i -g gulp-cli@3' + }, + node: { + range: '>=22.14.0 <23.0.0', + fix: 'Install Node.js >=22.14.0 <23.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + }, + '1.21.1': { + gulpCli: { + range: '^1 || ^2 || ^3', + fix: 'npm i -g gulp-cli@3' + }, + node: { + range: '>=22.14.0 <23.0.0', + fix: 'Install Node.js >=22.14.0 <23.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + }, + '1.22.0-beta.1': { + node: { + range: '>=22.14.0 <23.0.0', + fix: 'Install Node.js >=22.14.0 <23.0.0' + }, + sp: SharePointVersion.SPO, + yo: { + range: '^4 || ^5', + fix: 'npm i -g yo@5' + } + } +}; \ No newline at end of file diff --git a/src/m365/spfx/commands/project/DeployWorkflow.ts b/src/m365/spfx/commands/project/DeployWorkflow.ts index 0d1a3b7d7f2..760c03c13e1 100644 --- a/src/m365/spfx/commands/project/DeployWorkflow.ts +++ b/src/m365/spfx/commands/project/DeployWorkflow.ts @@ -14,7 +14,7 @@ export const workflow: GitHubWorkflow = { "build-and-deploy": { "runs-on": "ubuntu-latest", env: { - NodeVersion: "22.x" + NodeVersion: "" }, steps: [ { @@ -115,7 +115,7 @@ export const pipeline: AzureDevOpsPipeline = { }, { name: "NodeVersion", - value: "22.x" + value: "" } ], stages: [ diff --git a/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.spec.ts b/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.spec.ts index 6a6a08bd04e..7506e8b8be8 100644 --- a/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.spec.ts +++ b/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.spec.ts @@ -8,6 +8,7 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; +import { spfx } from '../../../../utils/spfx.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; @@ -22,6 +23,7 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').callsFake(() => ''); + sinon.stub(spfx, 'getHighestNodeVersion').returns('22.0.x'); sinon.stub(session, 'getId').callsFake(() => ''); commandInfo = cli.getCommandInfo(command); }); @@ -44,6 +46,7 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { afterEach(() => { sinonUtil.restore([ (command as any).getProjectRoot, + (command as any).getProjectVersion, fs.existsSync, fs.readFileSync, fs.writeFileSync @@ -89,6 +92,8 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { return ''; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.16.0'); + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); await command.action(logger, { options: { name: 'test', branchName: 'dev', skipFeatureDeployment: true, loginMethod: 'user', scope: 'sitecollection', siteUrl: 'https://contoso.sharepoint.com/sites/project' } } as any); @@ -148,12 +153,74 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { return ''; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); await command.action(logger, { options: { debug: true } } as any); assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.azuredevops', 'pipelines', 'deploy-spfx-solution.yml')), 'workflow file not created'); }); + it('handles error with unknown minor version of SPFx when missing minor version', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.azuredevops')) { + return true; + } + else if (fakePath.toString().endsWith('pipelines')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns(''); + + sinon.stub(fs, 'writeFileSync').throws(new Error('writeFileSync failed')); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError('Unable to determine the version of the current SharePoint Framework project. Could not find the correct version based on @microsoft/generator-sharepoint property in the .yo-rc.json file.')); + }); + + it('handles error with not found node version', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.azuredevops')) { + return true; + } + else if (fakePath.toString().endsWith('pipelines')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns('99.99.99'); + + sinon.stub(fs, 'writeFileSync').throws(new Error('writeFileSync failed')); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError(`Could not find Node version for version '99.99.99' of SharePoint Framework.`)); + }); + it('handles unexpected error', async () => { sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); @@ -176,9 +243,43 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { return false; }); - sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + + sinon.stub(fs, 'writeFileSync').throws(new Error('writeFileSync failed')); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError('writeFileSync failed')); + }); + + it('handles unexpected non-error value', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.azuredevops')) { + return true; + } + else if (fakePath.toString().endsWith('pipelines')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + + sinon.stub(fs, 'writeFileSync').callsFake(() => { + throw 'string failure'; + }); await assert.rejects(command.action(logger, { options: {} } as any), - new CommandError('error')); + new CommandError('string failure')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts b/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts index 6830ce245f9..2a5624a1d9f 100644 --- a/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts +++ b/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts @@ -10,6 +10,8 @@ import { pipeline } from './DeployWorkflow.js'; import { fsUtil } from '../../../../utils/fsUtil.js'; import { AzureDevOpsPipeline, AzureDevOpsPipelineStep } from './project-azuredevops-pipeline-model.js'; import GlobalOptions from '../../../../GlobalOptions.js'; +import { versions } from '../SpfxCompatibilityMatrix.js'; +import { spfx } from '../../../../utils/spfx.js'; interface CommandArgs { options: Options; @@ -128,7 +130,12 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand { this.savePipeline(pipeline); } catch (error: any) { - throw new CommandError(error); + if (error instanceof CommandError) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + throw new CommandError(message); } } @@ -155,6 +162,22 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand { pipeline.trigger.branches.include[0] = options.branchName; } + const version = this.getProjectVersion(); + + if (!version) { + throw new CommandError('Unable to determine the version of the current SharePoint Framework project. Could not find the correct version based on @microsoft/generator-sharepoint property in the .yo-rc.json file.'); + } + + const versionRequirements = versions[version]; + + if (!versionRequirements) { + throw new CommandError(`Could not find Node version for version '${version}' of SharePoint Framework.`); + } + + const nodeVersion: string = spfx.getHighestNodeVersion(versionRequirements.node.range); + + this.assignPipelineVariables(pipeline, 'NodeVersion', nodeVersion); + const script = this.getScriptAction(pipeline); if (script.script) { if (options.loginMethod === 'user') { @@ -213,4 +236,4 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand { } } -export default new SpfxProjectAzureDevOpsPipelineAddCommand(); \ No newline at end of file +export default new SpfxProjectAzureDevOpsPipelineAddCommand(); diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts index 856804c2be4..92e7a63e86a 100644 --- a/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts +++ b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts @@ -8,6 +8,7 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; +import { spfx } from '../../../../utils/spfx.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; @@ -22,6 +23,7 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').callsFake(() => ''); + sinon.stub(spfx, 'getHighestNodeVersion').returns('22.0.x'); sinon.stub(session, 'getId').callsFake(() => ''); commandInfo = cli.getCommandInfo(command); }); @@ -44,6 +46,7 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { afterEach(() => { sinonUtil.restore([ (command as any).getProjectRoot, + (command as any).getProjectVersion, fs.existsSync, fs.readFileSync, fs.writeFileSync @@ -116,6 +119,8 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { return ''; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); await command.action(logger, { options: { debug: true } } as any); @@ -149,12 +154,75 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { return ''; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); await command.action(logger, { options: { name: 'test', branchName: 'dev', manuallyTrigger: true, skipFeatureDeployment: true, loginMethod: 'user', scope: 'sitecollection' } } as any); assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.github', 'workflows', 'deploy-spfx-solution.yml')), 'workflow file not created'); }); + it('handles error with unknown version of SPFx', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.github')) { + return true; + } + else if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns(undefined); + + sinon.stub(fs, 'writeFileSync').throws(new Error('writeFileSync failed')); + + await assert.rejects(command.action(logger, { options: {} }), + new CommandError('Unable to determine the version of the current SharePoint Framework project. Could not find the correct version based on @microsoft/generator-sharepoint property in the .yo-rc.json file.')); + + }); + + it('handles error with not found node version', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.github')) { + return true; + } + else if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns('99.99.99'); + + sinon.stub(fs, 'writeFileSync').throws(new Error('writeFileSync failed')); + + await assert.rejects(command.action(logger, { options: {} }), + new CommandError(`Could not find Node version for 99.99.99 of SharePoint Framework`)); + }); + it('handles unexpected error', async () => { sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); @@ -177,9 +245,45 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { return false; }); - sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + + sinon.stub(fs, 'writeFileSync').callsFake(() => { + throw new Error('writeFileSync failed'); + }); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError('writeFileSync failed')); + }); + + it('handles unexpected non-error value', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.github')) { + return true; + } + else if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + + sinon.stub(fs, 'writeFileSync').callsFake(() => { + throw 'string failure'; + }); await assert.rejects(command.action(logger, { options: {} } as any), - new CommandError('error')); + new CommandError('string failure')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.ts b/src/m365/spfx/commands/project/project-github-workflow-add.ts index 200a5f2a01d..c6f721dc6d8 100644 --- a/src/m365/spfx/commands/project/project-github-workflow-add.ts +++ b/src/m365/spfx/commands/project/project-github-workflow-add.ts @@ -10,6 +10,8 @@ import commands from '../../commands.js'; import { workflow } from './DeployWorkflow.js'; import { BaseProjectCommand } from './base-project-command.js'; import { GitHubWorkflow, GitHubWorkflowStep } from './project-github-workflow-model.js'; +import { versions } from '../SpfxCompatibilityMatrix.js'; +import { spfx } from '../../../../utils/spfx.js'; interface CommandArgs { options: Options; @@ -133,7 +135,12 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { this.saveWorkflow(workflow); } catch (error: any) { - throw new CommandError(error); + if (error instanceof CommandError) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + throw new CommandError(message); } } @@ -155,6 +162,22 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { workflow.on.push.branches[0] = options.branchName; } + const version = this.getProjectVersion(); + + if (!version) { + throw new CommandError('Unable to determine the version of the current SharePoint Framework project. Could not find the correct version based on @microsoft/generator-sharepoint property in the .yo-rc.json file.'); + } + + const versionRequirements = versions[version]; + + if (!versionRequirements) { + throw new CommandError(`Could not find Node version for ${version} of SharePoint Framework`); + } + + const nodeVersion: string = spfx.getHighestNodeVersion(versionRequirements.node.range); + + this.assignNodeVersion(workflow, nodeVersion); + if (options.manuallyTrigger) { // eslint-disable-next-line camelcase workflow.on.workflow_dispatch = null; @@ -184,6 +207,10 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { } } + private assignNodeVersion(workflow: GitHubWorkflow, nodeVersion: string): void { + workflow.jobs['build-and-deploy'].env.NodeVersion = nodeVersion; + } + private getLoginAction(workflow: GitHubWorkflow): GitHubWorkflowStep { const steps = this.getWorkFlowSteps(workflow); return steps.find(step => step.uses && step.uses.indexOf('action-cli-login') >= 0)!; @@ -199,4 +226,4 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { } } -export default new SpfxProjectGithubWorkflowAddCommand(); \ No newline at end of file +export default new SpfxProjectGithubWorkflowAddCommand(); diff --git a/src/m365/spfx/commands/spfx-doctor.ts b/src/m365/spfx/commands/spfx-doctor.ts index 59f19dde7be..a5a77aa787c 100644 --- a/src/m365/spfx/commands/spfx-doctor.ts +++ b/src/m365/spfx/commands/spfx-doctor.ts @@ -5,6 +5,7 @@ import { Logger } from '../../../cli/Logger.js'; import { CheckStatus, formatting } from '../../../utils/formatting.js'; import commands from '../commands.js'; import { BaseProjectCommand } from './project/base-project-command.js'; +import { SharePointVersion, SpfxVersionPrerequisites, VersionCheck, versions } from './SpfxCompatibilityMatrix.js'; interface CommandArgs { options: Options; @@ -33,34 +34,6 @@ enum HandlePromise { Continue } -interface VersionCheck { - /** - * Required version range in semver - */ - range: string; - /** - * What to do to fix it if the required range isn't met - */ - fix: string; -} - -/** - * Versions of SharePoint that support SharePoint Framework - */ -enum SharePointVersion { - SP2016 = 1 << 0, - SP2019 = 1 << 1, - SPO = 1 << 2, - All = ~(~0 << 3) -} - -interface SpfxVersionPrerequisites { - gulpCli?: VersionCheck; - node: VersionCheck; - sp: SharePointVersion; - yo: VersionCheck; -} - export interface SpfxDoctorCheck { check: string; passed: boolean; @@ -70,589 +43,6 @@ export interface SpfxDoctorCheck { } class SpfxDoctorCommand extends BaseProjectCommand { - private readonly versions: { [version: string]: SpfxVersionPrerequisites } = { - '1.0.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6', - fix: 'Install Node.js v6' - }, - sp: SharePointVersion.All, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.1.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6', - fix: 'Install Node.js v6' - }, - sp: SharePointVersion.All, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.2.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6', - fix: 'Install Node.js v6' - }, - sp: SharePointVersion.SP2019 | SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.4.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6', - fix: 'Install Node.js v6' - }, - sp: SharePointVersion.SP2019 | SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.4.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6 || ^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SP2019 | SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.5.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6 || ^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.5.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6 || ^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.6.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^6 || ^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.7.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.7.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.8.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.8.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8', - fix: 'Install Node.js v8' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.8.2': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8 || ^10', - fix: 'Install Node.js v10' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.9.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^8 || ^10', - fix: 'Install Node.js v10' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.9.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^10', - fix: 'Install Node.js v10' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.10.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^10', - fix: 'Install Node.js v10' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.11.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^10', - fix: 'Install Node.js v10' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.12.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12', - fix: 'Install Node.js v12' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.12.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12 || ^14', - fix: 'Install Node.js v12 or v14' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^3', - fix: 'npm i -g yo@3' - } - }, - '1.13.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12 || ^14', - fix: 'Install Node.js v12 or v14' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.13.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12 || ^14', - fix: 'Install Node.js v12 or v14' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.14.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12 || ^14', - fix: 'Install Node.js v12 or v14' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.15.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12.13 || ^14.15 || ^16.13', - fix: 'Install Node.js v12.13, v14.15, v16.13 or higher' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.15.2': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '^12.13 || ^14.15 || ^16.13', - fix: 'Install Node.js v12.13, v14.15, v16.13 or higher' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.16.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.16.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.17.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.17.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.17.2': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.17.3': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.17.4': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.18.0': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.18.1': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4', - fix: 'npm i -g yo@4' - } - }, - '1.18.2': { - gulpCli: { - range: '^1 || ^2', - fix: 'npm i -g gulp-cli@2' - }, - node: { - range: '>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0', - fix: 'Install Node.js >=16.13.0 <17.0.0 || >=18.17.1 <19.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - }, - '1.19.0': { - gulpCli: { - range: '^1 || ^2 || ^3', - fix: 'npm i -g gulp-cli@3' - }, - node: { - range: '>=18.17.1 <19.0.0', - fix: 'Install Node.js >=18.17.1 <19.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - }, - '1.20.0': { - gulpCli: { - range: '^1 || ^2 || ^3', - fix: 'npm i -g gulp-cli@3' - }, - node: { - range: '>=18.17.1 <19.0.0', - fix: 'Install Node.js >=18.17.1 <19.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - }, - '1.21.0': { - gulpCli: { - range: '^1 || ^2 || ^3', - fix: 'npm i -g gulp-cli@3' - }, - node: { - range: '>=22.14.0 < 23.0.0', - fix: 'Install Node.js >=22.14.0 < 23.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - }, - '1.21.1': { - gulpCli: { - range: '^1 || ^2 || ^3', - fix: 'npm i -g gulp-cli@3' - }, - node: { - range: '>=22.14.0 < 23.0.0', - fix: 'Install Node.js >=22.14.0 < 23.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - }, - '1.22.0-beta.1': { - node: { - range: '>=22.14.0 < 23.0.0', - fix: 'Install Node.js >=22.14.0 < 23.0.0' - }, - sp: SharePointVersion.SPO, - yo: { - range: '^4 || ^5', - fix: 'npm i -g yo@5' - } - } - }; private output: string = ''; private resultsObject: SpfxDoctorCheck[] = []; @@ -695,7 +85,7 @@ class SpfxDoctorCommand extends BaseProjectCommand { }, { option: '-v, --spfxVersion [spfxVersion]', - autocomplete: Object.keys(this.versions) + autocomplete: Object.keys(versions) } ); } @@ -711,8 +101,8 @@ class SpfxDoctorCommand extends BaseProjectCommand { } if (args.options.spfxVersion) { - if (!this.versions[args.options.spfxVersion]) { - return `${args.options.spfxVersion} is not a supported SharePoint Framework version. Supported versions are ${Object.keys(this.versions).join(', ')}`; + if (!versions[args.options.spfxVersion]) { + return `${args.options.spfxVersion} is not a supported SharePoint Framework version. Supported versions are ${Object.keys(versions).join(', ')}`; } } @@ -751,7 +141,7 @@ class SpfxDoctorCommand extends BaseProjectCommand { throw `SharePoint Framework not found`; } - prerequisites = this.versions[spfxVersion]; + prerequisites = versions[spfxVersion]; if (!prerequisites) { const message = `spfx doctor doesn't support SPFx v${spfxVersion} at this moment`; @@ -1126,4 +516,4 @@ class SpfxDoctorCommand extends BaseProjectCommand { } } -export default new SpfxDoctorCommand(); +export default new SpfxDoctorCommand(); \ No newline at end of file diff --git a/src/utils/spfx.spec.ts b/src/utils/spfx.spec.ts index 6c80d5e0fd1..56cb940d7d5 100644 --- a/src/utils/spfx.spec.ts +++ b/src/utils/spfx.spec.ts @@ -27,4 +27,41 @@ describe('utils/spfx', () => { packageJson: {} }), false); }); -}); \ No newline at end of file + + it('returns correct Node version for a given range', () => { + const version = spfx.getHighestNodeVersion('>=14.0.0 <15.0.0 || >=16.0.0 <17.0.0'); + assert.strictEqual(version, '16.0.0'); + }); + + it('returns correct Node version for a single version', () => { + const version = spfx.getHighestNodeVersion('^10'); + assert.strictEqual(version, '10.x'); + }); + + it('returns correct Node version for a range with multiple versions', () => { + const version = spfx.getHighestNodeVersion('^12.13 || ^14.15 || ^16.13'); + assert.strictEqual(version, '16.13.x'); + }); + + it('returns correct Node version when only minor version differ', () => { + const version = spfx.getHighestNodeVersion('8.1 || 8.2'); + assert.strictEqual(version, '8.2.x'); + }); + + it('returns highest major for disjoint ranges', () => { + const version = spfx.getHighestNodeVersion('>=16.13.0 <17.0.0 || >=18.17.1 <19.0.0'); + assert.strictEqual(version, '18.17.1'); + }); + + it('throws when range is empty', () => { + assert.throws(() => spfx.getHighestNodeVersion(''), new Error('Node version range was not provided.')); + }); + + it('throws when range cannot be normalized', () => { + assert.throws(() => spfx.getHighestNodeVersion('invalid-range'), new Error("Unable to resolve the highest Node version for range 'invalid-range'.")); + }); + + it('throws when min version cannot be determined', () => { + assert.throws(() => spfx.getHighestNodeVersion('invalid || >=1.0.0 <1.0.0'), new Error("Unable to resolve the highest Node version for range 'invalid || >=1.0.0 <1.0.0'.")); + }); +}); diff --git a/src/utils/spfx.ts b/src/utils/spfx.ts index 63c5afa4d24..b8d4c2e51d4 100644 --- a/src/utils/spfx.ts +++ b/src/utils/spfx.ts @@ -1,3 +1,4 @@ +import { minVersion, SemVer, validRange } from 'semver'; import { Project } from '../m365/spfx/commands/project/project-model/index.js'; export const spfx = { @@ -14,5 +15,60 @@ export const spfx = { typeof project.yoRcJson['@microsoft/generator-sharepoint'] !== 'undefined' && project.yoRcJson["@microsoft/generator-sharepoint"].framework === 'knockout') || typeof project.packageJson?.dependencies?.['knockout'] !== 'undefined'; + }, + + getHighestNodeVersion(versionRange: string): string { + if (!versionRange) { + throw new Error('Node version range was not provided.'); + } + + const ranges = versionRange + .split('||') + .map(range => range.trim()) + .filter(range => range.length > 0); + + let highest: { version: SemVer; source: string } | null = null; + + for (const range of ranges) { + const normalized = validRange(range); + if (!normalized) { + continue; + } + + const minimum = minVersion(normalized); + if (!minimum) { + continue; + } + + if (!highest || minimum.compare(highest.version) > 0) { + highest = { + version: minimum, + source: range + }; + } + } + + if (!highest) { + throw new Error(`Unable to resolve the highest Node version for range '${versionRange}'.`); + } + + const source = highest.source.trim(); + const compactSource = source.replace(/\s+/g, ''); + const isCaretOrTilde = compactSource.startsWith('^') || compactSource.startsWith('~'); + const isSimpleVersion = /^[0-9]+(\.[0-9]+){0,2}$/.test(compactSource); + + if (isCaretOrTilde || isSimpleVersion) { + const numeric = isCaretOrTilde ? compactSource.substring(1) : compactSource; + const parts = numeric.split('.').filter(part => part.length > 0); + const { major } = highest.version; + + if (parts.length >= 2) { + return `${major}.${parts[1]}.x`; + } + + return `${major}.x`; + } + + return highest.version.version; } -}; \ No newline at end of file +};