Angular 7 with Azure DevOps Build Pipeline

Angular 7 is a very useful JavaScript framework for building applications. Being able to build these projects on Azure DevOps Pipelines is very useful.

Updated January 27th 2019 with Linting and E2E tests.

Angular Project

I've created a new repository on GitHub here.

Azure DevOps

Following the Angular Quickstart here I created a new folder and put Angular 7 project in there.

> mkdir src
> cd src
> npm install -g @angular/cli
> ng new angular7

Azure DevOps Pipeline definition

I tend to like storing my build definitions inside my repository with my code, so I've added an "azure-pipelines.yml" to the root of the repo with the following definition.

variables:
  buildConfiguration: 'Release'

steps:

- task: Npm@1
  displayName: 'npm install'
  inputs:
    command: install
    workingDir: src/angular7

- task: Npm@1
  displayName: 'Build Angular'
  inputs:
    command: custom
    customCommand: run build -- --prod
    workingDir: src/angular7

- task: PublishPipelineArtifact@0
  inputs:
    artifactName: 'angular'
    targetPath: 'src/angular7/dist'

In three steps, I can install NPM packages, build the Angular project, and then publish it as a build artifact.

One thing to call out on the build command is that anything after the -- is passed as an argument to whatever is contained in the build command. So the definition in package.json for "build": "ng build", becomes ng build --prod.

Pipeline

Even though the repository is stored in GitHub, I can still select it on Azure Build Pipelines.

Select repo

Azure Pipeline build definition will get automatically populated from the definition.

Pipeline definition

Click Run button on the top right. The pipeline will automatically be triggered anytime code happens to be committed to GitHub.

Angular Pipeline Build

The pipeline artifacts will be the Angular application.

This is a good start but there's some quality of life things that would be nice to add, such as tests and code-coverage.

Code-Coverage

Code coverage is a pretty neat thing to know about the application. Angular can generate a code-coverage report using this command: ng test --watch=false --code-coverage. Azure DevOps only has these two formats to accept for Code Coverage results.

Cobertura Pipeline

I'm going to add Cobertura output to karma.conf.js.

module.exports = function (config) {
...
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, '../coverage'),
      reports: ['html', 'lcovonly', 'text-summary', 'cobertura'],
      fixWebpackSourcePaths: true
    },
...
};

Running Angular tests spits out coverage files.

Angular Tests

Adding Puppeteer to dependencies makes it easier to run headless Chrome, especially with Angular on Azure DevOps.

> npm install puppeteer --save-dev

Editing the karma.conf.js makes it easier to run the tests.

module.exports = function (config) {
  const puppeteer = require('puppeteer');
  process.env.CHROME_BIN = puppeteer.executablePath();

  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular-devkit/build-angular/plugins/karma')
    ],
    client: {
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, '../coverage'),
      reports: ['html', 'lcovonly', 'text-summary', 'cobertura'],
      fixWebpackSourcePaths: true
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: false
  });
};

Further, adding the steps to the build pipeline to run this automatically.

- task: Npm@1
  displayName: 'Test Angular'
  inputs:
    command: custom
    customCommand: run test -- --watch=false --code-coverage
    workingDir: src/angular7

- task: PublishCodeCoverageResults@1
  displayName: 'Publish code coverage Angular results'
  condition: succeededOrFailed()
  inputs:
    codeCoverageTool: Cobertura
    summaryFileLocation: 'src/angular7/coverage/cobertura-coverage.xml'
    reportDirectory: src/angular7/coverage
    failIfCoverageEmpty: true

An interesting thing to note, is that I want to see code coverage results regardless of if a previous test failed:

condition: succeededOrFailed()

Code Coverage

Tests

Next, lets dig into adding tests output. I'm going to stick with tests from ng test, basically the tests being run above.

My options for publishing test results are JUnit, NUnit, XUnit, or VSTest

Test Result Options

Let's add the karma-junit-reporter.

> npm install karma-junit-reporter --save-dev

Further add it to the karma.conf.js configuration.

module.exports = function (config) {
  const puppeteer = require('puppeteer');
  process.env.CHROME_BIN = puppeteer.executablePath();

  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular-devkit/build-angular/plugins/karma'),
      require('karma-junit-reporter')
    ],
    client: {
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, '../coverage'),
      reports: ['html', 'lcovonly', 'text-summary', 'cobertura'],
      fixWebpackSourcePaths: true
    },
    reporters: ['progress', 'kjhtml', 'junit'],
    junitReporter: {
      outputDir: '../junit'
    },
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: false
  });
};

Now let's add Angular test results to the build pipeline. Note the same condition as previous code coverage, I want to see test results if succeeded, and especially if failed.

- task: PublishTestResults@2
  displayName: 'Publish Angular test results'
  condition: succeededOrFailed()
  inputs:
    searchFolder: $(System.DefaultWorkingDirectory)/src/angular7/junit
    testRunTitle: Angular
    testResultsFormat: JUnit
    testResultsFiles: "**/TESTS*.xml"

Add this as a beginning step, so leftover resutls don't get pulled in from previous builds.

- task: DeleteFiles@1
  displayName: 'Delete JUnit files'
  inputs:
    SourceFolder: src/angular7/junit
    Contents: 'TESTS*.xml'

Next time the build runs, you can see test results uploaded

Test Results

Linting

The next piece of the puzzle is to add linting to the results. In reality, this is just static analysis to make sure that the code follows guidelines. An example being to have semi-colons at the end of lines as shown in this image.

Lint Results


Adding lint output to the Azure DevOps build is as simple as adding this task to the end.

- task: Npm@1
  displayName: 'Lint Angular'
  inputs:
    command: custom
    customCommand: run lint --  --format=stylish
    workingDir: src/angular7

E2E (end-to-end) tests

There's one more level of tests that can be run, end-to-end tests. Using protractor, Angular runs e2e tests which are application-level tests. The previous tests shown in this pipeline are unit tests in isolation for individual components, directives, and other Angular pieces. These e2e tests run the entire application.

The process here is very similar to the tests from above, I'm going to edit the protractor.conf.js file to use both Junit and puppeteer and headless chrome. I already have puppeteer in the package.json from above, so do add the jasmine reporters package.

> npm install jasmine-reporters --save-dev

protractor.conf.js:

const { SpecReporter } = require('jasmine-spec-reporter');
const { JUnitXmlReporter } = require('jasmine-reporters');

process.env.CHROME_BIN = process.env.CHROME_BIN || require("puppeteer").executablePath();

exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './src/**/*.e2e-spec.ts'
  ],
  capabilities: {
    'browserName': 'chrome',

    chromeOptions: {
      args: ["--headless", "--disable-gpu", "--window-size=1200,900"],
      binary: process.env.CHROME_BIN
    }
  },
  directConnect: true,
  baseUrl: 'http://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function () { }
  },
  onPrepare() {
    require('ts-node').register({
      project: require('path').join(__dirname, './tsconfig.e2e.json')
    });
    jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
    var junitReporter = new JUnitXmlReporter({
      savePath: require('path').join(__dirname, './junit'),
      consolidateAll: true
    });
    jasmine.getEnv().addReporter(junitReporter);
  }
};

Adding the e2e tests to the pipeline look extremely similar to the tests from above, but without the code coverage upload.

- task: Npm@1
  displayName: 'E2E Test Angular'
  inputs:
    command: custom
    customCommand: run e2e
    workingDir: src/angular7

- task: PublishTestResults@2
  displayName: 'Publish Angular E2E test results'
  condition: succeededOrFailed()
  inputs:
    searchFolder: $(System.DefaultWorkingDirectory)/src/angular7/e2e/junit
    testRunTitle: Angular_E2E
    testResultsFormat: JUnit
    testResultsFiles: "**/junit*.xml"

I can then go into Azure DevOps and see the E2E test results too.

Test Results

Build Logs

The logs for the build pipeline show the steps clearly, and you may inspect each one closer as convenient.

Logs

Build Summary

The build summary shows the high level view that I have 100% code coverage and 100% succeeding tests. If tests fail, or code coverage drops, that'll be shown here too.

Summary

Code Changes

Here's the overview of code changes.

Added Build Pipeline YAML definition:

variables:
  buildConfiguration: 'Release'

steps:

- task: DeleteFiles@1
  displayName: 'Delete JUnit files'
  inputs:
    SourceFolder: src/angular7/junit
    Contents: 'TEST*.xml'

- task: Npm@1
  displayName: 'npm install'
  inputs:
    command: install
    workingDir: src/angular7

- task: Npm@1
  displayName: 'Build Angular'
  inputs:
    command: custom
    customCommand: run build -- --prod
    workingDir: src/angular7

- task: PublishPipelineArtifact@0
  inputs:
    artifactName: 'angular'
    targetPath: 'src/angular7/dist'

- task: PublishBuildArtifacts@1
  inputs:
    PathtoPublish: 'src/angular7/dist'
    ArtifactName: angular2

- task: Npm@1
  displayName: 'Test Angular'
  inputs:
    command: custom
    customCommand: run test -- --watch=false --code-coverage
    workingDir: src/angular7

- task: PublishCodeCoverageResults@1
  displayName: 'Publish code coverage Angular results'
  condition: succeededOrFailed()
  inputs:
    codeCoverageTool: Cobertura
    summaryFileLocation: 'src/angular7/coverage/cobertura-coverage.xml'
    reportDirectory: src/angular7/coverage
    failIfCoverageEmpty: true

- task: PublishTestResults@2
  displayName: 'Publish Angular test results'
  condition: succeededOrFailed()
  inputs:
    searchFolder: $(System.DefaultWorkingDirectory)/src/angular7/junit
    testRunTitle: Angular
    testResultsFormat: JUnit
    testResultsFiles: "**/TESTS*.xml"

- task: Npm@1
  displayName: 'Lint Angular'
  inputs:
    command: custom
    customCommand: run lint --  --format=stylish
    workingDir: src/angular7

- task: Npm@1
  displayName: 'E2E Test Angular'
  inputs:
    command: custom
    customCommand: run e2e
    workingDir: src/angular7

- task: PublishTestResults@2
  displayName: 'Publish Angular E2E test results'
  condition: succeededOrFailed()
  inputs:
    searchFolder: $(System.DefaultWorkingDirectory)/src/angular7/e2e/junit
    testRunTitle: Angular_E2E
    testResultsFormat: JUnit
    testResultsFiles: "**/junit*.xml"

package.json additions:

{
  "devDependencies": {
    "karma-junit-reporter": "^1.2.0",
    "puppeteer": "^1.11.0",
    "jasmine-reporters": "^2.3.2",
  }
}

Final karma.conf.js:

module.exports = function (config) {
  const puppeteer = require('puppeteer');
  process.env.CHROME_BIN = puppeteer.executablePath();

  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular-devkit/build-angular/plugins/karma'),
      require('karma-junit-reporter')
    ],
    client: {
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, '../coverage'),
      reports: ['html', 'lcovonly', 'text-summary', 'cobertura'],
      fixWebpackSourcePaths: true
    },
    reporters: ['progress', 'kjhtml', 'junit'],
    junitReporter: {
      outputDir: '../junit'
    },
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: false
  });
};

Final protractor.conf.js:

const { SpecReporter } = require('jasmine-spec-reporter');
const { JUnitXmlReporter } = require('jasmine-reporters');

process.env.CHROME_BIN = process.env.CHROME_BIN || require("puppeteer").executablePath();

exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './src/**/*.e2e-spec.ts'
  ],
  capabilities: {
    'browserName': 'chrome',

    chromeOptions: {
      args: ["--headless", "--disable-gpu", "--window-size=1200,900"],
      binary: process.env.CHROME_BIN
    }
  },
  directConnect: true,
  baseUrl: 'http://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function () { }
  },
  onPrepare() {
    require('ts-node').register({
      project: require('path').join(__dirname, './tsconfig.e2e.json')
    });
    jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
    var junitReporter = new JUnitXmlReporter({
      savePath: require('path').join(__dirname, './junit'),
      consolidateAll: true
    });
    jasmine.getEnv().addReporter(junitReporter);
  }
};

Summary

My code is here. Azure DevOps makes builds easy.