Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
646 changes: 646 additions & 0 deletions src/m365/spfx/commands/SpfxCompatibilityMatrix.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/m365/spfx/commands/project/DeployWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const workflow: GitHubWorkflow = {
"build-and-deploy": {
"runs-on": "ubuntu-latest",
env: {
NodeVersion: "22.x"
NodeVersion: ""
},
steps: [
{
Expand Down Expand Up @@ -115,7 +115,7 @@ export const pipeline: AzureDevOpsPipeline = {
},
{
name: "NodeVersion",
value: "22.x"
value: ""
}
],
stages: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,11 +18,12 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => {
let log: any[];
let logger: Logger;
let commandInfo: CommandInfo;
const projectPath: string = 'test-project';
const projectPath: string = path.resolve('/test-project');

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);
});
Expand All @@ -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
Expand All @@ -63,36 +66,38 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => {
});

it('creates a default workflow with specifying options', async () => {
sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath));
sinon.stub(command as any, 'getProjectRoot').returns(projectPath);

sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
if (fakePath.toString().endsWith('pipelines')) {
if (fakePath.toString() === path.join(projectPath, '.azuredevops', 'pipelines')) {
return true;
}

return false;
});

sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
if (path.toString().endsWith('package.json') && options === 'utf-8') {
sinon.stub(fs, 'readFileSync').callsFake((fakePath, options) => {
if (fakePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') {
return '{"name": "test"}';
}

return '';
throw `Invalid path: ${fakePath}`;
});

sinon.stub(fs, 'mkdirSync').callsFake((path, options) => {
if (path.toString().endsWith('.azuredevops') && (options as fs.MakeDirectoryOptions).recursive) {
return `${projectPath}/.azuredevops`;
sinon.stub(fs, 'mkdirSync').callsFake((fakePath, options) => {
if (fakePath.toString() === path.join(projectPath, '.azuredevops') && (options as fs.MakeDirectoryOptions).recursive) {
return path.join(projectPath, '.azuredevops');
}

return '';
throw `Invalid path: ${fakePath}`;
});

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);
assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.azuredevops', 'pipelines', 'deploy-spfx-solution.yml')), 'workflow file not created');
assert(writeFileSyncStub.calledWith(path.resolve(path.join(projectPath, '.azuredevops', 'pipelines', 'deploy-spfx-solution.yml'))), 'workflow file not created');
});

it('fails validation if loginMethod is not valid type', async () => {
Expand Down Expand Up @@ -128,57 +133,121 @@ describe(commands.PROJECT_AZUREDEVOPS_PIPELINE_ADD, () => {
});

it('creates a default workflow (debug)', async () => {
sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath));
sinon.stub(command as any, 'getProjectRoot').returns(projectPath);
sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
if (fakePath.toString().endsWith('.azuredevops')) {
if (fakePath.toString() === path.join(projectPath, '.azuredevops')) {
return true;
}
else if (fakePath.toString().endsWith('pipelines')) {
else if (fakePath.toString() === path.join(projectPath, '.azuredevops', 'pipelines')) {
return true;
}

return false;
throw `Invalid path: ${fakePath}`;
});

sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
if (path.toString().endsWith('package.json') && options === 'utf-8') {
sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => {
if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') {
return '{"name": "test"}';
}

return '';
throw `Invalid path: ${filePath}`;
});

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');
assert(writeFileSyncStub.calledWith(path.resolve(path.join(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(projectPath);

sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => {
if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') {
return '{"name": "test"}';
}

throw `Invalid path: ${filePath}`;
});

sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
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(command as any, 'getProjectVersion').returns(undefined);

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 the version property in the .yo-rc.json file.'));
});

it('handles error with not found node version', async () => {
sinon.stub(command as any, 'getProjectRoot').returns(projectPath);

sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => {
if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') {
return '{"name": "test"}';
}

throw `Invalid path: ${filePath}`;
});

sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
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(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));
sinon.stub(command as any, 'getProjectRoot').returns(projectPath);

sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
if (path.toString().endsWith('package.json') && options === 'utf-8') {
sinon.stub(fs, 'readFileSync').callsFake((filePath, options) => {
if (filePath.toString() === path.join(projectPath, 'package.json') && options === 'utf-8') {
return '{"name": "test"}';
}

return '';
throw `Invalid path: ${filePath}`;
});

sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
if (fakePath.toString().endsWith('.azuredevops')) {
if (fakePath.toString() === path.join(projectPath, '.azuredevops')) {
return true;
}
else if (fakePath.toString().endsWith('pipelines')) {
else if (fakePath.toString() === path.join(projectPath, '.azuredevops', 'pipelines')) {
return true;
}

return false;
throw `Invalid path: ${fakePath}`;
});

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('error'));
new CommandError('writeFileSync failed'));
Comment on lines +248 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, I find it weird that this test should fail. v1.21.1 is a valid SPFx version, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is valid. This tests checks how the command will behave when we simulate and unexpected error in the writeFileSync, so at the very end when we update the pipeline after retrieving the SPFx version and related changes and we want to finish of with saving the file

});
});
});
22 changes: 20 additions & 2 deletions src/m365/spfx/commands/project/project-azuredevops-pipeline-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,7 +130,7 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand {
this.savePipeline(pipeline);
}
catch (error: any) {
throw new CommandError(error);
this.handleError(error);
}
}

Expand All @@ -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 the version 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') {
Expand Down Expand Up @@ -213,4 +231,4 @@ class SpfxProjectAzureDevOpsPipelineAddCommand extends BaseProjectCommand {
}
}

export default new SpfxProjectAzureDevOpsPipelineAddCommand();
export default new SpfxProjectAzureDevOpsPipelineAddCommand();
Loading