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..ba0cc2cc76f 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('error'); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError(`Unable to determine the version of the current SharePoint Framework project`)); + }); + + 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('error'); + + await assert.rejects(command.action(logger, { options: {} } as any), + 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)); @@ -176,6 +243,8 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { return false; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; }); await assert.rejects(command.action(logger, { options: {} } as any), 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..dc23314debe 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; @@ -155,6 +157,22 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand { pipeline.trigger.branches.include[0] = options.branchName; } + const version = this.getProjectVersion(); + + if (!version) { + throw '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 `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') { 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..a642805c6c1 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('error'); + + await assert.rejects(command.action(logger, { options: {} }), + new CommandError(`Unable to determine the version of the current SharePoint Framework project`)); + + }); + + 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('error'); + + 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,6 +245,8 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { return false; }); + sinon.stub(command as any, 'getProjectVersion').returns('1.21.1'); + sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; }); await assert.rejects(command.action(logger, { options: {} } as any), 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..7e35a432f4d 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; @@ -155,6 +157,22 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { workflow.on.push.branches[0] = options.branchName; } + const version = this.getProjectVersion(); + + if (!version) { + throw '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 `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 +202,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)!; diff --git a/src/m365/spfx/commands/spfx-doctor.ts b/src/m365/spfx/commands/spfx-doctor.ts index c36f99d3d43..1a08c468e10 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..d7715cdac3a 100644 --- a/src/utils/spfx.spec.ts +++ b/src/utils/spfx.spec.ts @@ -27,4 +27,24 @@ describe('utils/spfx', () => { packageJson: {} }), false); }); + + 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, '17.0.x'); + }); + + it('returns correct Node version for a single version', () => { + const version = spfx.getHighestNodeVersion('^10'); + assert.strictEqual(version, '10.0.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'); + }); }); \ No newline at end of file diff --git a/src/utils/spfx.ts b/src/utils/spfx.ts index 63c5afa4d24..d229d45dd51 100644 --- a/src/utils/spfx.ts +++ b/src/utils/spfx.ts @@ -14,5 +14,38 @@ 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 { + const ranges = versionRange.split('||').map(r => r.trim()); + + const versions = ranges.map(range => { + if (range.includes('<')) { + const upperBound = range.split('<')[1].trim().split(' ')[0]; + const parts = upperBound.split('.'); + return `${parts[0]}.${parts[1]}`; + } + + const cleaned = range.replace(/[\^>=<~]/g, ''); + const parts = cleaned.split('.'); + + if (parts.length >= 2) { + return `${parts[0]}.${parts[1]}`; + } + + return `${parts[0]}.0`; + }); + + const sorted = versions.sort((a, b) => { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + + if (aMajor !== bMajor) { + return bMajor - aMajor; + } + return bMinor - aMinor; + }); + + return `${sorted[0]}.x`; } }; \ No newline at end of file