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 7e99f0efe67..48e7c01263f 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 @@ -179,6 +179,49 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => { assert(writeFileSyncStub.calledWith(path.resolve(path.join(projectPath, '.azuredevops', 'pipelines', 'deploy-spfx-solution.yml'))), 'workflow file not created'); }); + it('creates a pipeline with heft for SPFx version that requires heft', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(projectPath); + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString() === path.join(projectPath, 'package.json')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, 'config', 'package-solution.json')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, '.azuredevops')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, '.azuredevops', 'pipelines')) { + return true; + } + + throw `Invalid path: ${fakePath}`; + }); + + sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => { + if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + else if (filePath.toString() === path.join(projectPath, 'config', 'package-solution.json') && options === 'utf-8') { + return '{"paths": {"zippedPackage": "solution/test.sppkg"}}'; + } + + throw `Invalid path: ${filePath}`; + }); + + sinon.stub(command as any, 'getProjectVersion').returns('1.22.0'); + + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').callsFake(() => { }); + + await command.action(logger, { options: {} } as any); + + assert(writeFileSyncStub.calledWith(path.resolve(path.join(projectPath, '.azuredevops', 'pipelines', 'deploy-spfx-solution.yml'))), 'workflow file not created'); + const writtenContent: string = writeFileSyncStub.args[0][1] as string; + assert(writtenContent.includes('Heft build and package'), 'Heft step not found in pipeline'); + assert(!writtenContent.includes('Gulp bundle'), 'Gulp bundle step should be removed'); + assert(!writtenContent.includes('Gulp package'), 'Gulp package step should be removed'); + }); + it('handles error with unknown minor version of SPFx when missing minor version', async () => { sinon.stub(command as any, 'getProjectRoot').returns(projectPath); 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 9b5c637df09..f5d7bdd72ea 100644 --- a/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts +++ b/src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts @@ -214,6 +214,31 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand { if (options.skipFeatureDeployment) { script.script = script.script.replace(`m365 spo app deploy `, `m365 spo app deploy --skipFeatureDeployment `); } + + if (versionRequirements.heft !== undefined) { + let steps = this.getPipelineSteps(pipeline); + + const bundleStep = steps.find(step => step.task && step.displayName === "Gulp bundle"); + if (bundleStep) { + pipeline.stages[0].jobs[0].steps = steps.filter(step => step !== bundleStep); + } + + const packageStep = steps.find(step => step.task && step.displayName === "Gulp package"); + if (packageStep) { + pipeline.stages[0].jobs[0].steps = pipeline.stages[0].jobs[0].steps.filter(step => step !== packageStep); + } + + const installHeftStep: AzureDevOpsPipelineStep = { + task: "CmdLine@2", + displayName: "Heft build and package", + inputs: { + script: `npm install -g @rushstack/heft@latest\nheft build --production\nheft package-solution --production\n` + } + }; + steps = this.getPipelineSteps(pipeline); + const installCLIStepIndex = steps.findIndex(step => step.task === "Npm@1" && step.displayName === "Install CLI for Microsoft 365"); + pipeline.stages[0].jobs[0].steps.splice(installCLIStepIndex, 0, installHeftStep); + } } } diff --git a/src/m365/spfx/commands/project/project-azuredevops-pipeline-model.ts b/src/m365/spfx/commands/project/project-azuredevops-pipeline-model.ts index 6ef3365fec0..cecf26becb8 100644 --- a/src/m365/spfx/commands/project/project-azuredevops-pipeline-model.ts +++ b/src/m365/spfx/commands/project/project-azuredevops-pipeline-model.ts @@ -34,5 +34,6 @@ export interface AzureDevOpsPipelineStep { arguments?: string; verbose?: boolean; customCommand?: string; + script?: string; } } \ No newline at end of file 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 f7ebec99c9a..ec043583cf5 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 @@ -180,6 +180,53 @@ describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { assert(writeFileSyncStub.calledWith(path.resolve(path.join(projectPath, '.github', 'workflows', 'deploy-spfx-solution.yml'))), 'workflow file not created'); }); + it('uses heft build and package for newer version of SPFx', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(projectPath); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString() === path.join(projectPath, '.github')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, '.github', 'workflows')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, 'package.json')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, '.yo-rc.json')) { + return true; + } + else if (fakePath.toString() === path.join(projectPath, 'config', 'package-solution.json')) { + return true; + } + + return false; + }); + + sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => { + if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + else if (filePath.toString() === path.join(projectPath, '.yo-rc.json') && options === 'utf-8') { + return '{"@microsoft/generator-sharepoint": {"version": "1.22.0"}}'; + } + else if (filePath.toString() === path.join(projectPath, 'config', 'package-solution.json') && options === 'utf-8') { + return '{"paths": {"zippedPackage": "solution/test.sppkg"}}'; + } + + throw `Invalid path: ${filePath}`; + }); + + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').returns(); + + await command.action(logger, { options: {} } as any); + + assert(writeFileSyncStub.calledOnce, 'writeFileSync not called'); + const writtenContent: string = writeFileSyncStub.args[0][1]; + assert(writtenContent.includes('heft build --production'), 'heft build not found in workflow'); + assert(writtenContent.includes('heft package-solution --production'), 'heft package-solution not found in workflow'); + }); + it('handles error with unknown version of SPFx', async () => { sinon.stub(command as any, 'getProjectRoot').returns(projectPath); 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 f2dbcc582f6..ea0905a8ae0 100644 --- a/src/m365/spfx/commands/project/project-github-workflow-add.ts +++ b/src/m365/spfx/commands/project/project-github-workflow-add.ts @@ -204,12 +204,23 @@ class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { const deployAction = this.getDeployAction(workflow); deployAction.with!.APP_FILE_PATH = deployAction.with!.APP_FILE_PATH!.replace('{{ sppkgPath }}', sppkgPath); } + + if (versionRequirements.heft !== undefined) { + const bundleAndPackageStep = this.getBundleAndPackageStep(workflow); + bundleAndPackageStep.run = `npm install -g @rushstack/heft@latest\nheft build --production\nheft package-solution --production\n`; + bundleAndPackageStep.name = 'Build & Package'; + } } private assignNodeVersion(workflow: GitHubWorkflow, nodeVersion: string): void { workflow.jobs['build-and-deploy'].env.NodeVersion = nodeVersion; } + private getBundleAndPackageStep(workflow: GitHubWorkflow): GitHubWorkflowStep { + const steps = this.getWorkFlowSteps(workflow); + return steps.find(step => step.run && step.name === 'Bundle & Package')!; + } + private getLoginAction(workflow: GitHubWorkflow): GitHubWorkflowStep { const steps = this.getWorkFlowSteps(workflow); return steps.find(step => step.uses && step.uses.indexOf('action-cli-login') >= 0)!;