diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..afc1c8b --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +*.spec.ts +*.test.json +__mocks__ +__tests__ diff --git a/collection.test.json b/collection.test.json new file mode 100644 index 0000000..7163331 --- /dev/null +++ b/collection.test.json @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Adds Angular Deploy Azure to the application without affecting any templates", + "factory": "./src/ng-add/index#ngAdd", + "schema": "./src/ng-add/schema.json", + "aliases": ["install"] + } + } +} diff --git a/package.json b/package.json index 27d60be..7f31aa5 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "typescript": "~3.4.0" }, "devDependencies": { + "@schematics/angular": "^8.2.0", "@types/chai": "^4.0.4", "@types/conf": "^2.1.0", "@types/configstore": "^4.0.0", diff --git a/src/ng-add/index.spec.ts b/src/ng-add/index.spec.ts new file mode 100644 index 0000000..7e1462f --- /dev/null +++ b/src/ng-add/index.spec.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; +import { Schema as ApplicationOptions } from '@schematics/angular/application/schema'; +import { confirm } from '../util/prompt/confirm'; + +jest.mock('../util/azure/auth'); +jest.mock('../util/azure/subscription'); +jest.mock('../util/azure/resource-group'); +jest.mock('../util/azure/account'); +jest.mock('../util/prompt/confirm'); + +const collectionPath = require.resolve('../../collection.test.json'); + +const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'tests', + version: '8.0.0' +}; + +const appOptions: ApplicationOptions = { name: 'test-app' }; + +describe('ng add @azure/ng-deploy', () => { + const testRunner = new SchematicTestRunner('schematics', collectionPath); + + async function initAngularProject(): Promise { + const appTree = await testRunner.runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions).toPromise(); + return await testRunner.runExternalSchematicAsync('@schematics/angular', 'application', appOptions, appTree).toPromise(); + } + + it('fails with a missing tree', async () => { + await expect(testRunner.runSchematicAsync('ng-add', {}, Tree.empty()).toPromise()).rejects.toThrow(); + }); + + it('adds azure deploy to an existing project', async () => { + let appTree = await initAngularProject(); + appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise() + const angularJson = JSON.parse(appTree.readContent('/angular.json')); + + expect(angularJson.projects[appOptions.name].architect.deploy).toBeDefined(); + expect(angularJson.projects[appOptions.name].architect.azureLogout).toBeDefined(); + expect(appTree.files).toContain('/azure.json'); + + const azureJson = JSON.parse(appTree.readContent('/azure.json')); + expect(azureJson).toEqual({ + hosting: [ + { + app: { + configuration: "production", + path: "dist/test-app", + project: "test-app", + target: "build", + }, + azureHosting: { + account: "fakeStorageAccount", + resourceGroupName: "fake-resource-group", + subscription: "fake-subscription-1234", + } + } + ] + }); + }); + + it('should overwrite existing hosting config', async () => { + // Simulate existing app setup + let appTree = await initAngularProject(); + appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise() + appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing')); + + const confirmMock = confirm as jest.Mock; + confirmMock.mockClear(); + confirmMock.mockImplementationOnce(() => Promise.resolve(true)); + + // Run ng add @azure/deploy on existing project + appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise() + + expect(confirm).toHaveBeenCalledTimes(1); + expect(appTree.files).toContain('/azure.json'); + + const azureJson = JSON.parse(appTree.readContent('/azure.json')); + expect(azureJson).toEqual({ + hosting: [ + { + app: { + configuration: "production", + path: "dist/test-app", + project: "test-app", + target: "build", + }, + azureHosting: { + account: "fakeStorageAccount", + resourceGroupName: "fake-resource-group", + subscription: "fake-subscription-1234", + } + } + ] + }); + }); + + it('should keep existing hosting config', async () => { + // Simulate existing app setup + let appTree = await initAngularProject(); + appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise() + appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing')); + + const confirmMock = confirm as jest.Mock; + confirmMock.mockClear(); + confirmMock.mockImplementationOnce(() => Promise.resolve(false)); + + // Run ng add @azure/deploy on existing project + appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise() + + expect(confirm).toHaveBeenCalledTimes(1); + expect(appTree.files).toContain('/azure.json'); + + const azureJson = JSON.parse(appTree.readContent('/azure.json')); + expect(azureJson).toEqual({ + hosting: [ + { + app: { + configuration: "production", + path: "dist/test-app", + project: "test-app", + target: "build", + }, + azureHosting: { + account: "existingStorageAccount", + resourceGroupName: "existing-resource-group", + subscription: "existing-subscription-1234", + } + } + ] + }); + }); +}); diff --git a/src/ng-add/index.ts b/src/ng-add/index.ts index d4d2d30..229690a 100644 --- a/src/ng-add/index.ts +++ b/src/ng-add/index.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { confirm } from '../util/prompt/confirm'; import { loginToAzure } from '../util/azure/auth'; import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth'; import { selectSubscription } from '../util/azure/subscription'; import { getResourceGroup } from '../util/azure/resource-group'; import { getAccount, getAzureStorageClient } from '../util/azure/account'; import { AngularWorkspace } from '../util/workspace/angular-json'; -import { generateAzureJson } from '../util/workspace/azure-json'; +import { generateAzureJson, readAzureJson, getAzureHostingConfig } from '../util/workspace/azure-json'; import { AddOptions } from '../util/shared/types'; export function ngAdd(_options: AddOptions): Rule { @@ -22,38 +23,38 @@ export function ngAdd(_options: AddOptions): Rule { export function addDeployAzure(_options: AddOptions): Rule { return async (tree: Tree, _context: SchematicContext) => { - // TODO: if azure.json already exists: get data / delete / error - const project = new AngularWorkspace(tree, _options); + const azureJson = readAzureJson(tree); + const hostingConfig = azureJson ? getAzureHostingConfig(azureJson, project.projectName) : null; + + if (!hostingConfig || await confirm(`Overwrite existing Azure config for ${ project.projectName }?`)) { + + const auth = await loginToAzure(_context.logger); + const credentials = auth.credentials as DeviceTokenCredentials; + const subscription = await selectSubscription(auth.subscriptions, _options, _context.logger); + const resourceGroup = await getResourceGroup(credentials, subscription, _options, _context.logger); + const client = getAzureStorageClient(credentials, subscription); + const account = await getAccount(client, resourceGroup, _options, _context.logger); + + const appDeployConfig = { + project: project.projectName, + target: project.target, + configuration: project.configuration, + path: project.path + }; + + const azureDeployConfig = { + subscription, + resourceGroupName: resourceGroup.name, + account + }; + + // TODO: log url for account at Azure portal + generateAzureJson(tree, appDeployConfig, azureDeployConfig); + + } - const auth = await loginToAzure(_context.logger); - const credentials = auth.credentials as DeviceTokenCredentials; project.addLogoutArchitect(); - - const subscription = await selectSubscription(auth.subscriptions, _options, _context.logger); - - const resourceGroup = await getResourceGroup(credentials, subscription, _options, _context.logger); - - const client = getAzureStorageClient(credentials, subscription); - - const account = await getAccount(client, resourceGroup, _options, _context.logger); - - const appDeployConfig = { - project: project.projectName, - target: project.target, - configuration: project.configuration, - path: project.path - }; - - const azureDeployConfig = { - subscription, - resourceGroupName: resourceGroup.name, - account - }; - - // TODO: log url for account at Azure portal - generateAzureJson(tree, appDeployConfig, azureDeployConfig); - project.addDeployArchitect(); }; } diff --git a/src/ng-add/index_spec.ts b/src/ng-add/index_spec.ts deleted file mode 100644 index 8c867c7..0000000 --- a/src/ng-add/index_spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { Tree } from '@angular-devkit/schematics'; -import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; -import * as path from 'path'; - - -const collectionPath = path.join(__dirname, '../collection.json'); - - -describe('ng-deploy-azure', () => { - it('works', () => { - const runner = new SchematicTestRunner('schematics', collectionPath); - const tree = runner.runSchematic('ng-deploy-azure', {}, Tree.empty()); - - expect(tree.files).toEqual([]); - }); -}); diff --git a/src/util/azure/__mocks__/account.ts b/src/util/azure/__mocks__/account.ts new file mode 100644 index 0000000..1cd521a --- /dev/null +++ b/src/util/azure/__mocks__/account.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const getAccount = () => 'fakeStorageAccount'; + +export const getAzureStorageClient = () => null; diff --git a/src/util/azure/__mocks__/auth.ts b/src/util/azure/__mocks__/auth.ts new file mode 100644 index 0000000..d65c359 --- /dev/null +++ b/src/util/azure/__mocks__/auth.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const loginToAzure = () => Promise.resolve({ + credentials: null, + subscriptions: [] +}); diff --git a/src/util/azure/__mocks__/resource-group.ts b/src/util/azure/__mocks__/resource-group.ts new file mode 100644 index 0000000..1287366 --- /dev/null +++ b/src/util/azure/__mocks__/resource-group.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + export const getResourceGroup = () => Promise.resolve({ + id: '4321', + name: 'fake-resource-group', + location: 'westus' + }); diff --git a/src/util/azure/__mocks__/subscription.ts b/src/util/azure/__mocks__/subscription.ts new file mode 100644 index 0000000..08f4fab --- /dev/null +++ b/src/util/azure/__mocks__/subscription.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + export const selectSubscription = () => Promise.resolve('fake-subscription-1234'); + \ No newline at end of file diff --git a/src/util/azure/account.ts b/src/util/azure/account.ts index 683fa31..9429b63 100644 --- a/src/util/azure/account.ts +++ b/src/util/azure/account.ts @@ -181,7 +181,7 @@ export async function createAccount( `https://${ account }.blob.core.windows.net`, pipeline ); - spinner.start('setting container to be publicly available static site'); + spinner.start('Setting container to be publicly available static site'); await setStaticSiteToPublic(serviceURL); spinner.succeed(); } diff --git a/src/util/prompt/confirm.ts b/src/util/prompt/confirm.ts new file mode 100644 index 0000000..6f52c0c --- /dev/null +++ b/src/util/prompt/confirm.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { prompt } from 'inquirer'; + +export async function confirm(message: string, confirmByDefault: boolean = false): Promise { + const { ok } = await prompt<{ ok: any }>([ + { + type: 'confirm', + name: 'ok', + default: confirmByDefault, + message + } + ]); + return ok; +} diff --git a/src/util/workspace/azure-json.ts b/src/util/workspace/azure-json.ts index 7ba8d63..3042a6f 100644 --- a/src/util/workspace/azure-json.ts +++ b/src/util/workspace/azure-json.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { SchematicsException, Tree } from '@angular-devkit/schematics'; +const azureJsonFile = 'azure.json'; + export interface AzureDeployConfig { subscription: string; resourceGroupName: string; @@ -26,18 +28,30 @@ export interface AzureJSON { hosting: AzureHostingConfig[]; } -export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) { - const path = 'azure.json'; - const azureJson: AzureJSON = tree.exists(path) ? safeReadJSON(path, tree) : emptyAzureJson(); +export function readAzureJson(tree: Tree): AzureJSON | null { + return tree.exists(azureJsonFile) ? safeReadJSON(azureJsonFile, tree) : null; +} - if (azureJson.hosting.find(config => config.app.project === appDeployConfig.project)) { - // TODO: if exists - update? - throw new SchematicsException(`Target ${ appDeployConfig.project } already exists in ${ path }`); +export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) { + const azureJson: AzureJSON = readAzureJson(tree) || emptyAzureJson(); + const existingHostingConfigIndex = getAzureHostingConfigIndex(azureJson, appDeployConfig.project); + const hostingConfig = generateHostingConfig(appDeployConfig, azureDeployConfig); + + if (existingHostingConfigIndex >= 0) { + azureJson.hosting[existingHostingConfigIndex] = hostingConfig; + } else { + azureJson.hosting.push(hostingConfig); } - azureJson.hosting.push(generateHostingConfig(appDeployConfig, azureDeployConfig)); + overwriteIfExists(tree, azureJsonFile, stringifyFormatted(azureJson)); +} - overwriteIfExists(tree, path, stringifyFormatted(azureJson)); +export function getAzureHostingConfig(azureJson: AzureJSON, projectName: string): AzureHostingConfig | undefined { + return azureJson.hosting.find(config => config.app.project === projectName); +} + +function getAzureHostingConfigIndex(azureJson: AzureJSON, project: string): number { + return azureJson.hosting.findIndex(config => config.app.project === project); } const overwriteIfExists = (tree: Tree, path: string, content: string) => { @@ -68,7 +82,6 @@ function safeReadJSON(path: string, tree: Tree) { } } - function generateHostingConfig(appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) { return { app: appDeployConfig,