From cb35fb516d33e0f196bb0568c5f7833cdd94150e Mon Sep 17 00:00:00 2001 From: Yannick Reekmans Date: Fri, 12 Jul 2019 00:23:55 +0200 Subject: [PATCH] Added the 'pa solution reference add' command solving #954 --- .../cmd/pa/solution/solution-reference-add.md | 37 ++ docs/manual/mkdocs.yml | 1 + npm-shrinkwrap.json | 6 + package.json | 8 +- src/o365/pa/cds-project-mutator.spec.ts | 317 ++++++++++++++++++ src/o365/pa/cds-project-mutator.ts | 105 ++++++ src/o365/pa/commands.ts | 3 +- src/o365/pa/commands/pcf/pcf-init.ts | 2 +- .../commands/solution/solution-init.spec.ts | 18 +- .../pa/commands/solution/solution-init.ts | 4 +- .../solution/solution-reference-add.spec.ts | 311 +++++++++++++++++ .../solution/solution-reference-add.ts | 156 +++++++++ tsconfig.json | 2 +- 13 files changed, 953 insertions(+), 17 deletions(-) create mode 100644 docs/manual/docs/cmd/pa/solution/solution-reference-add.md create mode 100644 src/o365/pa/cds-project-mutator.spec.ts create mode 100644 src/o365/pa/cds-project-mutator.ts create mode 100644 src/o365/pa/commands/solution/solution-reference-add.spec.ts create mode 100644 src/o365/pa/commands/solution/solution-reference-add.ts diff --git a/docs/manual/docs/cmd/pa/solution/solution-reference-add.md b/docs/manual/docs/cmd/pa/solution/solution-reference-add.md new file mode 100644 index 00000000000..c6cc2f5fb91 --- /dev/null +++ b/docs/manual/docs/cmd/pa/solution/solution-reference-add.md @@ -0,0 +1,37 @@ +# pa solution reference add + +Adds a project reference to the solution in the current directory + +## Usage + +```sh +pa solution reference add [options] +``` + +## Options + +Option|Description +------|----------- +`--help`|output usage information +`-p, --path `|The path to the referenced project +`-o, --output [output]`|Output type. `json|text`. Default `text` +`--verbose`|Runs command with verbose logging +`--debug`|Runs command with debug logging + +## Remarks + +This commands expects a CDS solution project in the current directory, and references a PowerApps component framework project. + +The CDS solution project and the PowerApps component framework project cannot have the same name. + +## Examples + +Adds a reference inside the CDS Solution project in the current directory to the PowerApps component framework project at `./projects/ExampleProject` + +```sh +pa solution reference add --path ./projects/ExampleProject +``` + +## More information + +- Create and build a custom component: [https://docs.microsoft.com/en-us/powerapps/developer/component-framework/create-custom-controls-using-pcf](https://docs.microsoft.com/en-us/powerapps/developer/component-framework/create-custom-controls-using-pcf) diff --git a/docs/manual/mkdocs.yml b/docs/manual/mkdocs.yml index 2e00ab65df5..02b7e3a4b0d 100644 --- a/docs/manual/mkdocs.yml +++ b/docs/manual/mkdocs.yml @@ -73,6 +73,7 @@ nav: - pcf init: 'cmd/pa/pcf/pcf-init.md' - solution: - solution init: 'cmd/pa/solution/solution-init.md' + - solution reference add: 'cmd/pa/solution/solution-reference-add.md' - Microsoft Planner (planner): - task: - task list: 'cmd/planner/task/task-list.md' diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 47925ad568c..9068a3edb13 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -319,6 +319,12 @@ "integrity": "sha512-YV+ZcSIiv30GhLM7WwxI+bsbcW34d3Yhl2JSFBNFL6qtfsoI9++hogxz+jTqeS86ynKcMUE0AsnLWQynfJnsfA==", "dev": true }, + "@types/xmldom": { + "version": "0.1.29", + "resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.29.tgz", + "integrity": "sha1-xEKLDKhtO4gUdXJv2UmAs4onw4E=", + "dev": true + }, "adal-node": { "version": "0.1.28", "resolved": "https://registry.npmjs.org/adal-node/-/adal-node-0.1.28.tgz", diff --git a/package.json b/package.json index 7daee81b19e..6c34437c10c 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "adal-node": "^0.1.28", "applicationinsights": "^1.4.0", "easy-table": "^1.1.1", + "node-forge": "0.8.5", "omelette": "^0.4.12", "request-promise-native": "^1.0.7", "semver": "^6.1.1", @@ -112,23 +113,24 @@ "update-notifier": "^3.0.0", "uuid": "^3.3.2", "vorpal": "https://github.com/pnp/vorpal/raw/master/vorpal-1.11.6.tgz", - "node-forge": "0.8.5" + "xmldom": "^0.1.27" }, "devDependencies": { "@types/easy-table": "0.0.32", "@types/mocha": "^5.2.7", "@types/node": "^10.14.8", + "@types/node-forge": "0.8.4", "@types/request": "^2.48.1", "@types/request-promise-native": "^1.0.16", "@types/semver": "^6.0.0", "@types/sinon": "^5.0.5", "@types/update-notifier": "^2.5.0", + "@types/xmldom": "^0.1.29", "coveralls": "^3.0.4", "mocha": "^6.1.4", "nyc": "^13.3.0", "rimraf": "^2.6.3", - "sinon": "^7.3.2", - "@types/node-forge": "0.8.4" + "sinon": "^7.3.2" }, "nyc": { "exclude": [ diff --git a/src/o365/pa/cds-project-mutator.spec.ts b/src/o365/pa/cds-project-mutator.spec.ts new file mode 100644 index 00000000000..df69de666ba --- /dev/null +++ b/src/o365/pa/cds-project-mutator.spec.ts @@ -0,0 +1,317 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { XMLSerializer } from 'xmldom'; +import CdsProjectMutator from './cds-project-mutator'; + +describe('CdsProjectMutator', () => { + const pcfProjectFilePath: string = path.join('../path/to/projectDirectory', 'project.pcfproj'); + + it('Executes when no project reference already exists', () => { + const cdsProjectMutator = new CdsProjectMutator(` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + +`); + + assert.doesNotThrow(() => { + cdsProjectMutator.addProjectReference(pcfProjectFilePath) + }); + + assert.equal(new XMLSerializer().serializeToString(cdsProjectMutator.cdsProjectDocument), ` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`); + }); + + it('Executes when a project reference already exists', () => { + const cdsProjectMutator = new CdsProjectMutator(` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`); + + assert.doesNotThrow(() => { + cdsProjectMutator.addProjectReference(pcfProjectFilePath) + }); + + assert.equal(new XMLSerializer().serializeToString(cdsProjectMutator.cdsProjectDocument), ` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`); + }); + + it('Executes when no ItemGroups exists', () => { + const cdsProjectMutator = new CdsProjectMutator(` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + +`); + + assert.doesNotThrow(() => { + cdsProjectMutator.addProjectReference(pcfProjectFilePath) + }); + + assert.equal(new XMLSerializer().serializeToString(cdsProjectMutator.cdsProjectDocument), ` + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 00cad86b-4f87-4bcc-958c-b89640a96c21 + v4.6.2 + + net462 + PackageReference + + + + + + + + + +`); + }); + + it('Executes when no PropertyGroups exists', () => { + const cdsProjectMutator = new CdsProjectMutator(` + + + + + + + + +`); + + assert.doesNotThrow(() => { + cdsProjectMutator.addProjectReference(pcfProjectFilePath) + }); + + assert.equal(new XMLSerializer().serializeToString(cdsProjectMutator.cdsProjectDocument), ` + + + + + + + + + + + + +`); + }); + + it('Executes when no ImportGroups exists', () => { + const cdsProjectMutator = new CdsProjectMutator(` + +`); + + assert.doesNotThrow(() => { + cdsProjectMutator.addProjectReference(pcfProjectFilePath) + }); + + assert.equal(new XMLSerializer().serializeToString(cdsProjectMutator.cdsProjectDocument), ` + + + + + +`); + }); +}); diff --git a/src/o365/pa/cds-project-mutator.ts b/src/o365/pa/cds-project-mutator.ts new file mode 100644 index 00000000000..8364a384289 --- /dev/null +++ b/src/o365/pa/cds-project-mutator.ts @@ -0,0 +1,105 @@ +import * as path from 'path'; +import { DOMParser } from 'xmldom'; + +/* + * Logic extracted from bolt.module.solution.dll + * Version: 0.4.3 + * Class: bolt.module.solution.CdsProjectMutator + */ +export default class CdsProjectMutator { + private _cdsProjectDocument: Document; + private _cdsProject: HTMLElement; + private _cdsNamespace: string; + + public get cdsProjectDocument(): Document { + return this._cdsProjectDocument; + } + + public constructor(document: string) { + this._cdsProjectDocument = new DOMParser().parseFromString(document, 'text/xml'); + this._cdsProject = this._cdsProjectDocument.documentElement; + this._cdsNamespace = this._cdsProject.lookupNamespaceURI('') || ''; + } + + public addProjectReference(referencedProjectPath: string): void { + if (!this.doesProjectReferenceExists(referencedProjectPath)) { + const projectReferenceElement = this.createProjectReferenceElement(referencedProjectPath); + var projectReferenceItemGroup = this.getProjectReferenceItemGroup(); + if (projectReferenceItemGroup) { + this.addProjectReferenceElement(projectReferenceItemGroup, projectReferenceElement); + } + else { + projectReferenceItemGroup = this.createProjectReferenceItemGroup(projectReferenceElement); + this.addProjectReferenceItemGroupElement(projectReferenceItemGroup); + } + } + } + + private doesProjectReferenceExists(referencedProjectPath: string): boolean { + return this.getNamedGroups('ItemGroup').some(itemGroup => { + return this.getProjectReferencesFromItemGroup(itemGroup).some(projectReference => { + const projectReferencePath = projectReference.getAttributeNode('Include'); + return (projectReferencePath && path.normalize(projectReferencePath.value).toLowerCase() === referencedProjectPath.toLowerCase()); + }); + }); + } + + private getNamedGroups(name: string): Element[] { + return Array.from(this._cdsProject.getElementsByTagNameNS(this._cdsNamespace, name)); + } + + private getProjectReferencesFromItemGroup(itemGroup: Element): Element[] { + return Array.from(itemGroup.getElementsByTagNameNS(this._cdsNamespace, 'ProjectReference')); + } + + private getProjectReferenceItemGroup(): Element | null { + const itemGroups = this.getNamedGroups('ItemGroup').filter(itemGroup => this.getProjectReferencesFromItemGroup(itemGroup).length > 0); + return itemGroups.length > 0 ? itemGroups[0] : null; + } + + private createProjectReferenceElement(referencedProjectPath: string): Node { + var projectReferenceElement = this._cdsProjectDocument.createElementNS(this._cdsNamespace, 'ProjectReference'); + projectReferenceElement.setAttributeNS(this._cdsNamespace, 'Include', referencedProjectPath); + return projectReferenceElement; + } + + private createProjectReferenceItemGroup(projectReferenceElement: Node): Element { + var projectReferenceItemGroup = this._cdsProjectDocument.createElementNS(this._cdsNamespace, 'ItemGroup'); + projectReferenceItemGroup.appendChild(this._cdsProjectDocument.createTextNode('\n ')); + this.addProjectReferenceElement(projectReferenceItemGroup, projectReferenceElement); + return projectReferenceItemGroup; + } + + private addProjectReferenceElement(projectReferenceItemGroup: Element, projectReferenceElement: Node): void { + projectReferenceItemGroup.appendChild(this._cdsProjectDocument.createTextNode(' ')); + projectReferenceItemGroup.appendChild(projectReferenceElement); + projectReferenceItemGroup.appendChild(this._cdsProjectDocument.createTextNode('\n ')); + } + + private addProjectReferenceItemGroupElement(projectReferenceItemGroup: Element): void { + const itemGroups = this.getNamedGroups('ItemGroup'); + if (itemGroups.length > 0) { + this._cdsProject.insertBefore(projectReferenceItemGroup, itemGroups[itemGroups.length - 1].nextSibling); + this._cdsProject.insertBefore(this._cdsProjectDocument.createTextNode('\n\n '), projectReferenceItemGroup); + } + else { + const propertyGroups = this.getNamedGroups('PropertyGroup'); + if (propertyGroups.length > 0) { + this._cdsProject.insertBefore(projectReferenceItemGroup, propertyGroups[propertyGroups.length - 1].nextSibling); + this._cdsProject.insertBefore(this._cdsProjectDocument.createTextNode('\n\n '), projectReferenceItemGroup); + } + else { + const importGroups = this.getNamedGroups('Import'); + if (importGroups.length > 0) { + this._cdsProject.insertBefore(projectReferenceItemGroup, importGroups[0].nextSibling); + this._cdsProject.insertBefore(this._cdsProjectDocument.createTextNode('\n\n '), projectReferenceItemGroup); + } + else { + this._cdsProject.appendChild(this._cdsProjectDocument.createTextNode('\n ')); + this._cdsProject.appendChild(projectReferenceItemGroup); + this._cdsProject.appendChild(this._cdsProjectDocument.createTextNode('\n')); + } + } + } + } +} \ No newline at end of file diff --git a/src/o365/pa/commands.ts b/src/o365/pa/commands.ts index 153eb17981e..e82597e0559 100644 --- a/src/o365/pa/commands.ts +++ b/src/o365/pa/commands.ts @@ -2,5 +2,6 @@ const prefix: string = 'pa'; export default { PCF_INIT: `${prefix} pcf init`, - SOLUTION_INIT: `${prefix} solution init` + SOLUTION_INIT: `${prefix} solution init`, + SOLUTION_REFERENCE_ADD: `${prefix} solution reference add` }; diff --git a/src/o365/pa/commands/pcf/pcf-init.ts b/src/o365/pa/commands/pcf/pcf-init.ts index f577d165b26..23b1178aa7d 100644 --- a/src/o365/pa/commands/pcf/pcf-init.ts +++ b/src/o365/pa/commands/pcf/pcf-init.ts @@ -121,7 +121,7 @@ class PaPcfInitCommand extends Command { public validate(): CommandValidate { return (args: CommandArgs): boolean | string => { - if (fs.readdirSync(process.cwd()).some(fn => fn.endsWith('proj'))) { + if (fs.readdirSync(process.cwd()).some(fn => path.extname(fn).toLowerCase().endsWith('proj'))) { return 'PowerApps component framework project creation failed. The current directory already contains a project. Please create a new directory and retry the operation.'; } diff --git a/src/o365/pa/commands/solution/solution-init.spec.ts b/src/o365/pa/commands/solution/solution-init.spec.ts index 58012d8041c..dc23851089f 100644 --- a/src/o365/pa/commands/solution/solution-init.spec.ts +++ b/src/o365/pa/commands/solution/solution-init.spec.ts @@ -217,16 +217,16 @@ describe(commands.SOLUTION_INIT, () => { it('TemplateInstantiator.instantiate is called exactly twice when the CDS Assets Directory \'Other\' already exists in the current directory, but doesn\'t contain a Solution.xml file', () => { const originalExistsSync = fs.existsSync; - sinon.stub(fs, 'existsSync').callsFake((path: string) => { - if (path.endsWith('Other')) { + sinon.stub(fs, 'existsSync').callsFake((pathToCheck: string) => { + if(path.basename(pathToCheck).toLowerCase() === 'other') { return true; } - else if (path.endsWith('Solution.xml')) { + else if (path.basename(pathToCheck).toLowerCase() === 'solution.xml') { return false; } else { - return originalExistsSync(path); - } + return originalExistsSync(pathToCheck); + } }); const templateInstantiate = sinon.stub(TemplateInstantiator, 'instantiate').callsFake(() => { }); @@ -238,15 +238,15 @@ describe(commands.SOLUTION_INIT, () => { it('TemplateInstantiator.instantiate is called exactly once when the CDS Assets Directory \'Other\' already exists in the current directory and contains a Solution.xml file', () => { const originalExistsSync = fs.existsSync; - sinon.stub(fs, 'existsSync').callsFake((path: string) => { - if (path.endsWith('Other')) { + sinon.stub(fs, 'existsSync').callsFake((pathToCheck: string) => { + if(path.basename(pathToCheck).toLowerCase() === 'other') { return true; } - else if (path.endsWith('Solution.xml')) { + else if (path.basename(pathToCheck).toLowerCase() === 'solution.xml') { return true; } else { - return originalExistsSync(path); + return originalExistsSync(pathToCheck); } }); const templateInstantiate = sinon.stub(TemplateInstantiator, 'instantiate').callsFake(() => { }); diff --git a/src/o365/pa/commands/solution/solution-init.ts b/src/o365/pa/commands/solution/solution-init.ts index b49d5946a66..c6758f7adf6 100644 --- a/src/o365/pa/commands/solution/solution-init.ts +++ b/src/o365/pa/commands/solution/solution-init.ts @@ -27,7 +27,7 @@ interface Options extends GlobalOptions { /* * Logic extracted from bolt.module.solution.dll * Version: 0.4.3 - * Class: bolt.module.solution.SolutionInitVerb + * Class: bolt.module.solution.verbs.SolutionInitVerb */ class PaSolutionInitCommand extends Command { public get name(): string { @@ -120,7 +120,7 @@ class PaSolutionInitCommand extends Command { public validate(): CommandValidate { return (args: CommandArgs): boolean | string => { - if (fs.readdirSync(process.cwd()).some(fn => fn.endsWith('proj'))) { + if (fs.readdirSync(process.cwd()).some(fn => path.extname(fn).toLowerCase() === '.cdsproj')) { return 'CDS project creation failed. The current directory already contains a project. Please create a new directory and retry the operation.'; } diff --git a/src/o365/pa/commands/solution/solution-reference-add.spec.ts b/src/o365/pa/commands/solution/solution-reference-add.spec.ts new file mode 100644 index 00000000000..a9b73b376dd --- /dev/null +++ b/src/o365/pa/commands/solution/solution-reference-add.spec.ts @@ -0,0 +1,311 @@ +import commands from '../../commands'; +import Command, { CommandOption, CommandValidate } from '../../../../Command'; +import * as sinon from 'sinon'; +import appInsights from '../../../../appInsights'; +const command: Command = require('./solution-reference-add'); +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import Utils from '../../../../Utils'; +import CdsProjectMutator from '../../cds-project-mutator'; + +describe(commands.SOLUTION_REFERENCE_ADD, () => { + let vorpal: Vorpal; + let log: string[]; + let cmdInstance: any; + let trackEvent: any; + let telemetry: any; + + before(() => { + trackEvent = sinon.stub(appInsights, 'trackEvent').callsFake((t) => { + telemetry = t; + }); + }); + + beforeEach(() => { + vorpal = require('../../../../vorpal-init'); + log = []; + cmdInstance = { + commandWrapper: { + command: command.name + }, + action: command.action(), + log: (msg: string) => { + log.push(msg); + } + }; + telemetry = null; + }); + + afterEach(() => { + Utils.restore([ + vorpal.find, + fs.existsSync, + fs.readFileSync, + fs.writeFileSync, + path.relative, + fs.readdirSync, + CdsProjectMutator.prototype.addProjectReference + ]); + }); + + after(() => { + Utils.restore([ + appInsights.trackEvent + ]); + }); + + it('has correct name', () => { + assert.equal(command.name.startsWith(commands.SOLUTION_REFERENCE_ADD), true); + }); + + it('has a description', () => { + assert.notEqual(command.description, null); + }); + + it('calls telemetry', () => { + cmdInstance.action = command.action(); + cmdInstance.action({ options: {} }, () => { + assert(trackEvent.called); + }); + }); + + it('logs correct telemetry event', () => { + cmdInstance.action = command.action(); + cmdInstance.action({ options: {} }, () => { + assert.equal(telemetry.name, commands.SOLUTION_REFERENCE_ADD); + }); + }); + + it('supports specifying path', () => { + const options = (command.options() as CommandOption[]); + let containsOption = false; + options.forEach(o => { + if (o.option.indexOf('--path') > -1) { + containsOption = true; + } + }); + assert(containsOption); + }); + + it('fails validation when no *.cdsproj exists in the current directory', () => { + sinon.stub(fs, 'readdirSync').callsFake(() => []); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when more than one *.cdsproj exists in the current directory', () => { + sinon.stub(fs, 'readdirSync').callsFake(() => ['file1.cdsproj', 'file2.cdsproj']); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the path option isn\'t specified', () => { + sinon.stub(fs, 'readdirSync').callsFake(() => ['file1.cdsproj']); + + const actual = (command.validate() as CommandValidate)({ options: {} }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path option doesn\'t exist', () => { + sinon.stub(fs, 'readdirSync').callsFake(() => ['file1.cdsproj']); + sinon.stub(fs, 'existsSync').callsFake(() => false); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path contains no *.pcfproj or *.csproj file', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return []; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path contains no *.pcfproj or *.csproj file', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return []; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path contains two *.pcfproj files', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return ['file1.pcfproj', 'file2.pcfproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path contains two *.csproj files', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return ['file1.csproj', 'file2.csproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when the specified path contains both *.pcfproj and *.csproj files', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return ['file1.pcfproj', 'file2.csproj', 'file3.csproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('passes validation when current directory contains exactly one *.cdsproj file and the specified path contains exactly one *.pcfproj files', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['cdsfile1.cdsproj']; + } + return ['pcffile1.pcfproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.equal(actual, true); + }); + + it('passes validation when current directory contains exactly one *.cdsproj file and the specified path contains exactly one *.csproj files', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['cdsfile1.cdsproj']; + } + return ['csfile1.csproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.equal(actual, true); + }); + + it('fails validation when current directory contains exactly one *.cdsproj file and the specified path contains exactly one *.pcfproj file with the same name', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return ['file1.pcfproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('fails validation when current directory contains exactly one *.cdsproj file and the specified path contains exactly one *.csproj file with the same name', () => { + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === process.cwd()) { + return ['file1.cdsproj']; + } + return ['file1.csproj']; + }); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + const actual = (command.validate() as CommandValidate)({ options: { path: 'path/to/project' } }); + assert.notEqual(actual, true); + }); + + it('Creates an instance of CdsProjectMutator, adds project reference and saves updated file', () => { + const pathToDirectory = '../path/to/projectDirectory'; + const pcfProjectFile = 'project.pcfproj'; + const pathToPcfProject = path.join(pathToDirectory, pcfProjectFile); + const cdsProjectFile = 'cdsproject.cdsproj'; + const pathToCdsProject = path.join(process.cwd(), cdsProjectFile); + sinon.stub(fs, 'readdirSync').callsFake((path) => { + if (path === pathToDirectory) { + return [pcfProjectFile]; + } + else if (path === process.cwd()) { + return [cdsProjectFile]; + } + return []; + }); + const pathRelative = sinon.stub(path, 'relative').callsFake((from, to) => { + return pathToPcfProject; + }); + const fsReadFileSync = sinon.stub(fs, 'readFileSync').callsFake((path, encoding) => ''); + const addProjectReferenceStub = sinon.stub(CdsProjectMutator.prototype, 'addProjectReference').callsFake((path) => { }); + const fsWriteFileSync = sinon.stub(fs, 'writeFileSync').callsFake((path, contents) => { }); + + cmdInstance.action({ options: { path: pathToDirectory } }, () => { + assert(pathRelative.calledWith(process.cwd(), pathToPcfProject)); + assert(fsReadFileSync.calledWith(pathToCdsProject, 'utf8')); + assert(addProjectReferenceStub.calledWith(pathToPcfProject)); + assert(fsWriteFileSync.calledWith(pathToCdsProject, sinon.match.any)); + }); + }); + + it('supports verbose mode', () => { + const options = command.options() as CommandOption[]; + let containsOption = false; + options.forEach((o) => { + if (o.option === '--verbose') { + containsOption = true; + } + }); + assert(containsOption); + }); + + it('has help referring to the right command', () => { + const cmd: any = { + log: (msg: string) => { }, + prompt: () => { }, + helpInformation: () => { } + }; + const find = sinon.stub(vorpal, 'find').callsFake(() => cmd); + cmd.help = command.help(); + cmd.help({}, () => { }); + assert(find.calledWith(commands.SOLUTION_REFERENCE_ADD)); + }); + + it('has help with examples', () => { + const _log: string[] = []; + const cmd: any = { + log: (msg: string) => { + _log.push(msg); + }, + prompt: () => { }, + helpInformation: () => { } + }; + sinon.stub(vorpal, 'find').callsFake(() => cmd); + cmd.help = command.help(); + cmd.help({}, () => { }); + let containsExamples: boolean = false; + _log.forEach(l => { + if (l && l.indexOf('Examples:') > -1) { + containsExamples = true; + } + }); + Utils.restore(vorpal.find); + assert(containsExamples); + }); +}); \ No newline at end of file diff --git a/src/o365/pa/commands/solution/solution-reference-add.ts b/src/o365/pa/commands/solution/solution-reference-add.ts new file mode 100644 index 00000000000..fdfc8beb5d2 --- /dev/null +++ b/src/o365/pa/commands/solution/solution-reference-add.ts @@ -0,0 +1,156 @@ +import * as fs from "fs"; +import * as path from 'path'; +import commands from '../../commands'; +import GlobalOptions from '../../../../GlobalOptions'; +import Command, { + CommandOption, + CommandValidate, + CommandAction, + CommandError +} from '../../../../Command'; +import CdsProjectMutator from "../../cds-project-mutator"; + +const vorpal: Vorpal = require('../../../../vorpal-init'); + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + path: string; +} + +/* + * Logic extracted from bolt.module.solution.dll + * Version: 1.0.6 + * Class: bolt.module.solution.verbs.SolutionAddReferenceVerb + */ +class PaSolutionReferenceAddCommand extends Command { + public get name(): string { + return commands.SOLUTION_REFERENCE_ADD; + } + + public get description(): string { + return 'Adds a project reference to the solution in the current directory'; + } + + public action(): CommandAction { + const cmd: Command = this; + return function (this: CommandInstance, args: CommandArgs, cb: (err?: any) => void) { + args = (cmd as any).processArgs(args); + (cmd as any).initAction(args, this); + cmd.commandAction(this, args, cb); + } + } + + public commandAction(cmd: CommandInstance, args: CommandArgs, cb: (err?: any) => void): void { + try { + const referencedProjectFilePath: string = this.getSupportedProjectFiles(args.options.path)[0]; + const relativeReferencedProjectFilePath: string = path.relative(process.cwd(), referencedProjectFilePath); + const cdsProjectFilePath: string = this.getCdsProjectFile(process.cwd())[0]; + const cdsProjectFileContent: string = fs.readFileSync(cdsProjectFilePath, 'utf8'); + + const cdsProjectMutator = new CdsProjectMutator(cdsProjectFileContent); + cdsProjectMutator.addProjectReference(relativeReferencedProjectFilePath); + + fs.writeFileSync(cdsProjectFilePath, cdsProjectMutator.cdsProjectDocument); + + cb(); + } + catch (err) { + cb(new CommandError(err)); + } + } + + public options(): CommandOption[] { + const options: CommandOption[] = [ + { + option: '-p, --path ', + description: 'The path to the referenced project' + } + ]; + + const parentOptions: CommandOption[] = super.options(); + return options.concat(parentOptions); + } + + public validate(): CommandValidate { + return (args: CommandArgs): boolean | string => { + const existingCdsProjects: string[] = this.getCdsProjectFile(process.cwd()); + + if (existingCdsProjects.length === 0) { + return 'CDS solution project file with extension cdsproj was not found in the current directory.'; + } + + if (existingCdsProjects.length > 1) { + return 'Multiple CDS solution project files with extension cdsproj were found in the current directory.'; + } + + if (!args.options.path) { + return 'Missing required option path.'; + } + + if (!fs.existsSync(args.options.path)) { + return `Path ${args.options.path} is not a valid path.`; + } + + const existingSupportedProjects: string[] = this.getSupportedProjectFiles(args.options.path); + if (existingSupportedProjects.length === 0) { + return `No supported project type found in path ${args.options.path}.`; + } + + if (existingSupportedProjects.length !== 1) { + return `More than one supported project type found in path ${args.options.path}.`; + } + + const cdsProjectName: string = path.parse(path.basename(existingCdsProjects[0])).name; + const pcfProjectName: string = path.parse(path.basename(existingSupportedProjects[0])).name; + + if (cdsProjectName === pcfProjectName) { + return `Not able to add reference to a project with same name as CDS project with name: ${pcfProjectName}.`; + } + + return true; + }; + } + + private getCdsProjectFile(rootPath: string): string[] { + return fs.readdirSync(rootPath) + .filter(fn => path.extname(fn).toLowerCase() === '.cdsproj') + .map(entry => path.join(rootPath, entry)); + } + + private getSupportedProjectFiles(rootPath: string): string[] { + return fs.readdirSync(rootPath).filter(fn => { + const ext: string = path.extname(fn).toLowerCase(); + return ext === '.pcfproj' || ext === '.csproj'; + }).map(entry => path.join(rootPath, entry)); + } + + public commandHelp(args: CommandArgs, log: (help: string) => void): void { + const chalk = vorpal.chalk; + log(vorpal.find(commands.SOLUTION_REFERENCE_ADD).helpInformation()); + log( + ` Remarks: + + This commands expects a CDS solution project in the current directory, and + references a PowerApps component framework project. + + The CDS solution project and the PowerApps component framework project + cannot have the same name. + + Examples: + + Adds a reference inside the CDS Solution project in the current directory + to the PowerApps component framework project at ${chalk.grey('./projects/ExampleProject')} + ${commands.SOLUTION_REFERENCE_ADD} --path ./projects/ExampleProject + + More information: + + Create and build a custom component + https://docs.microsoft.com/en-us/powerapps/developer/component-framework/create-custom-controls-using-pcf +`); + } +} + +module.exports = new PaSolutionReferenceAddCommand(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8c096b34250..c401b099498 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ /* Basic Options */ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ - "lib": ["es2015"], /* Specify library files to be included in the compilation: */ + "lib": ["es2015", "dom"], /* Specify library files to be included in the compilation: */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */