diff --git a/services/azure-devops/azure-devops-base.js b/services/azure-devops/azure-devops-base.js
new file mode 100644
index 0000000000000..831a23df4da3a
--- /dev/null
+++ b/services/azure-devops/azure-devops-base.js
@@ -0,0 +1,64 @@
+'use strict'
+
+const Joi = require('joi')
+const BaseJsonService = require('../base-json')
+const { NotFound } = require('../errors')
+
+const latestBuildSchema = Joi.object({
+ count: Joi.number().required(),
+ value: Joi.array()
+ .items(
+ Joi.object({
+ id: Joi.number().required(),
+ })
+ )
+ .required(),
+}).required()
+
+module.exports = class BaseAzureDevOpsService extends BaseJsonService {
+ async fetch({ url, options, schema, errorMessages }) {
+ return this._requestJson({
+ schema,
+ url,
+ options,
+ errorMessages,
+ })
+ }
+
+ async getLatestBuildId(
+ organization,
+ project,
+ definitionId,
+ branch,
+ headers,
+ errorMessages
+ ) {
+ // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0
+ const url = `https://dev.azure.com/${organization}/${project}/_apis/build/builds`
+ const options = {
+ qs: {
+ definitions: definitionId,
+ $top: 1,
+ 'api-version': '5.0-preview.4',
+ },
+ headers,
+ }
+
+ if (branch) {
+ options.qs.branch = branch
+ }
+
+ const json = await this.fetch({
+ url,
+ options,
+ schema: latestBuildSchema,
+ errorMessages,
+ })
+
+ if (json.count !== 1) {
+ throw new NotFound({ prettyMessage: 'build pipeline not found' })
+ }
+
+ return json.value[0].id
+ }
+}
diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js
index ef90dbb412a2b..d03daba654bc7 100644
--- a/services/azure-devops/azure-devops-coverage.service.js
+++ b/services/azure-devops/azure-devops-coverage.service.js
@@ -1,8 +1,7 @@
'use strict'
const Joi = require('joi')
-const BaseJsonService = require('../base-json')
-const { NotFound } = require('../errors')
+const BaseAzureDevOpsService = require('./azure-devops-base')
const { keywords, getHeaders } = require('./azure-devops-helpers')
const documentation = `
@@ -29,17 +28,6 @@ const {
coveragePercentage: coveragePercentageColor,
} = require('../../lib/color-formatters')
-const latestBuildSchema = Joi.object({
- count: Joi.number().required(),
- value: Joi.array()
- .items(
- Joi.object({
- id: Joi.number().required(),
- })
- )
- .required(),
-}).required()
-
const buildCodeCoverageSchema = Joi.object({
coverageData: Joi.array()
.items(
@@ -59,7 +47,7 @@ const buildCodeCoverageSchema = Joi.object({
.required(),
}).required()
-module.exports = class AzureDevOpsCoverage extends BaseJsonService {
+module.exports = class AzureDevOpsCoverage extends BaseAzureDevOpsService {
static render({ coverage }) {
return {
message: `${coverage.toFixed(0)}%`,
@@ -113,52 +101,18 @@ module.exports = class AzureDevOpsCoverage extends BaseJsonService {
}
}
- async fetch({ url, options, schema }) {
- return this._requestJson({
- schema,
- url,
- options,
- errorMessages: {
- 404: 'build pipeline or coverage not found',
- },
- })
- }
-
- async getLatestBuildId(organization, project, definitionId, branch, headers) {
- // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0
- const url = `https://dev.azure.com/${organization}/${project}/_apis/build/builds`
- const options = {
- qs: {
- definitions: definitionId,
- $top: 1,
- 'api-version': '5.0-preview.4',
- },
- headers,
- }
- if (branch) {
- options.qs.branch = branch
- }
- const json = await this.fetch({
- url,
- options,
- schema: latestBuildSchema,
- })
-
- if (json.count !== 1) {
- throw new NotFound({ prettyMessage: 'build pipeline not found' })
- }
-
- return json.value[0].id
- }
-
async handle({ organization, project, definitionId, branch }) {
const headers = getHeaders()
+ const errorMessages = {
+ 404: 'build pipeline or coverage not found',
+ }
const buildId = await this.getLatestBuildId(
organization,
project,
definitionId,
branch,
- headers
+ headers,
+ errorMessages
)
// Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/test/code%20coverage/get%20build%20code%20coverage?view=azure-devops-rest-5.0
const url = `https://dev.azure.com/${organization}/${project}/_apis/test/codecoverage`
@@ -173,6 +127,7 @@ module.exports = class AzureDevOpsCoverage extends BaseJsonService {
url,
options,
schema: buildCodeCoverageSchema,
+ errorMessages,
})
let covered = 0
diff --git a/services/azure-devops/azure-devops-tests.service.js b/services/azure-devops/azure-devops-tests.service.js
new file mode 100644
index 0000000000000..f52c2c0aeaa19
--- /dev/null
+++ b/services/azure-devops/azure-devops-tests.service.js
@@ -0,0 +1,239 @@
+'use strict'
+
+const Joi = require('joi')
+const BaseAzureDevOpsService = require('./azure-devops-base')
+const { getHeaders } = require('./azure-devops-helpers')
+const { renderTestResultBadge } = require('../../lib/text-formatters')
+
+const documentation = `
+
+ To obtain your own badge, you need to get 3 pieces of information:
+ ORGANIZATION
, PROJECT
and DEFINITION_ID
.
+
+
+ First, you need to select your build definition and look at the url:
+
+
+
+ Your badge will then have the form:
+ https://img.shields.io/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg
.
+
+
+ Optionally, you can specify a named branch:
+ https://img.shields.io/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID/NAMED_BRANCH.svg
.
+
+
+ You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters &passed_label=
, &failed_label=
and &skipped_label=
respectively.
+
+ There is also a &compact_message
query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
+
+ For example, if you want to use a different terminology:
+
+ /azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?passed_label=good&failed_label=bad&skipped_label=n%2Fa
+
+ Or, use symbols:
+
+ /azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?compact_message&passed_label=%F0%9F%8E%89&failed_label=%F0%9F%92%A2&skipped_label=%F0%9F%A4%B7
+
+`
+
+const buildTestResultSummarySchema = Joi.object({
+ aggregatedResultsAnalysis: Joi.object({
+ totalTests: Joi.number().required(),
+ resultsByOutcome: Joi.object({
+ Passed: Joi.object({
+ count: Joi.number().required(),
+ }).optional(),
+ Failed: Joi.object({
+ count: Joi.number().required(),
+ }).optional(),
+ Skipped: Joi.object({
+ count: Joi.number().required(),
+ }).optional(),
+ }).required(),
+ }).required(),
+}).required()
+
+module.exports = class AzureDevOpsTests extends BaseAzureDevOpsService {
+ static render({
+ passed,
+ failed,
+ skipped,
+ total,
+ passedLabel,
+ failedLabel,
+ skippedLabel,
+ isCompact,
+ }) {
+ return renderTestResultBadge({
+ passed,
+ failed,
+ skipped,
+ total,
+ passedLabel,
+ failedLabel,
+ skippedLabel,
+ isCompact,
+ })
+ }
+
+ static get defaultBadgeData() {
+ return { label: 'tests' }
+ }
+
+ static get category() {
+ return 'build'
+ }
+
+ static get examples() {
+ return [
+ {
+ title: 'Azure DevOps tests',
+ pattern: ':organization/:project/:definitionId',
+ exampleUrl: 'azuredevops-powershell/azuredevops-powershell/1',
+ staticExample: this.render({
+ passed: 20,
+ failed: 1,
+ skipped: 1,
+ total: 22,
+ }),
+ keywords: ['vso', 'vsts', 'azure-devops'],
+ documentation,
+ },
+ {
+ title: 'Azure DevOps tests (branch)',
+ pattern: ':organization/:project/:definitionId/:branch',
+ exampleUrl: 'azuredevops-powershell/azuredevops-powershell/1/master',
+ staticExample: this.render({
+ passed: 20,
+ failed: 1,
+ skipped: 1,
+ total: 22,
+ }),
+ keywords: ['vso', 'vsts', 'azure-devops'],
+ documentation,
+ },
+ {
+ title: 'Azure DevOps tests (compact)',
+ pattern: ':organization/:project/:definitionId',
+ exampleUrl: 'azuredevops-powershell/azuredevops-powershell/1',
+ query: {
+ compact_message: null,
+ },
+ keywords: ['vso', 'vsts', 'azure-devops'],
+ staticExample: this.render({
+ passed: 20,
+ failed: 1,
+ skipped: 1,
+ total: 22,
+ isCompact: true,
+ }),
+ documentation,
+ },
+ {
+ title: 'Azure DevOps tests with custom labels',
+ pattern: ':organization/:project/:definitionId',
+ exampleUrl: 'azuredevops-powershell/azuredevops-powershell/1',
+ keywords: ['vso', 'vsts', 'azure-devops'],
+ query: {
+ passed_label: 'good',
+ failed_label: 'bad',
+ skipped_label: 'n/a',
+ },
+ staticExample: this.render({
+ passed: 20,
+ failed: 1,
+ skipped: 1,
+ total: 22,
+ passedLabel: 'good',
+ failedLabel: 'bad',
+ skippedLabel: 'n/a',
+ }),
+ documentation,
+ },
+ ]
+ }
+
+ static get route() {
+ return {
+ base: 'azure-devops/tests',
+ format: '([^/]+)/([^/]+)/([^/]+)(?:/(.+))?',
+ capture: ['organization', 'project', 'definitionId', 'branch'],
+ queryParams: [
+ 'compact_message',
+ 'passed_label',
+ 'failed_label',
+ 'skipped_label',
+ ],
+ }
+ }
+
+ async handle(
+ { organization, project, definitionId, branch },
+ {
+ compact_message: compactMessage,
+ passed_label: passedLabel,
+ failed_label: failedLabel,
+ skipped_label: skippedLabel,
+ }
+ ) {
+ const headers = getHeaders()
+ const errorMessages = {
+ 404: 'build pipeline or test result summary not found',
+ }
+ const buildId = await this.getLatestBuildId(
+ organization,
+ project,
+ definitionId,
+ branch,
+ headers,
+ errorMessages
+ )
+
+ // https://dev.azure.com/azuredevops-powershell/azuredevops-powershell/_apis/test/ResultSummaryByBuild?buildId=20
+ const url = `https://dev.azure.com/${organization}/${project}/_apis/test/ResultSummaryByBuild`
+ const options = {
+ qs: {
+ buildId,
+ },
+ headers,
+ }
+
+ const json = await this.fetch({
+ url,
+ options,
+ schema: buildTestResultSummarySchema,
+ errorMessages,
+ })
+
+ const total = json.aggregatedResultsAnalysis.totalTests
+
+ let passed = 0
+ const passedTests = json.aggregatedResultsAnalysis.resultsByOutcome.Passed
+ if (passedTests) {
+ passed = passedTests.count
+ }
+
+ let failed = 0
+ const failedTests = json.aggregatedResultsAnalysis.resultsByOutcome.Failed
+ if (failedTests) {
+ failed = failedTests.count
+ }
+
+ // assume the rest has been skipped
+ const skipped = total - passed - failed
+ const isCompact = compactMessage !== undefined
+ return this.constructor.render({
+ passed,
+ failed,
+ skipped,
+ total,
+ passedLabel,
+ failedLabel,
+ skippedLabel,
+ isCompact,
+ })
+ }
+}
diff --git a/services/azure-devops/azure-devops-tests.tester.js b/services/azure-devops/azure-devops-tests.tester.js
new file mode 100644
index 0000000000000..4c3ddc49065e7
--- /dev/null
+++ b/services/azure-devops/azure-devops-tests.tester.js
@@ -0,0 +1,195 @@
+'use strict'
+
+const Joi = require('joi')
+const t = require('../create-service-tester')()
+module.exports = t
+
+const org = 'azuredevops-powershell'
+const project = 'azuredevops-powershell'
+const definitionId = 1
+const nonExistentDefinitionId = 9999
+const buildId = 20
+const uriPrefix = `/${org}/${project}`
+const azureDevOpsApiBaseUri = `https://dev.azure.com/${org}/${project}/_apis`
+const mockBadgeUriPath = `${uriPrefix}/${definitionId}`
+const mockBadgeUri = `${mockBadgeUriPath}.json`
+const mockBranchBadgeUri = `${mockBadgeUriPath}/master.json`
+const mockLatestBuildApiUriPath = `/build/builds?definitions=${definitionId}&%24top=1&api-version=5.0-preview.4`
+const mockNonExistendBuildApiUriPath = `/build/builds?definitions=${nonExistentDefinitionId}&%24top=1&api-version=5.0-preview.4`
+const mockTestResultSummaryApiUriPath = `/test/ResultSummaryByBuild?buildId=${buildId}`
+const latestBuildResponse = {
+ count: 1,
+ value: [{ id: buildId }],
+}
+const mockEmptyTestResultSummaryResponse = {
+ aggregatedResultsAnalysis: {
+ totalTests: 0,
+ resultsByOutcome: {},
+ },
+}
+
+function getLabelRegex(label, isCompact) {
+ return isCompact ? `(?:${label} [0-9]*)` : `(?:[0-9]* ${label})`
+}
+
+function isAzureDevOpsTestTotals(
+ passedLabel,
+ failedLabel,
+ skippedLabel,
+ isCompact
+) {
+ const passedRegex = getLabelRegex(passedLabel, isCompact)
+ const failedRegex = getLabelRegex(failedLabel, isCompact)
+ const skippedRegex = getLabelRegex(skippedLabel, isCompact)
+ const separator = isCompact ? ' | ' : ', '
+ const regexStrings = [
+ `^${passedRegex}$`,
+ `^${failedRegex}$`,
+ `^${skippedRegex}`,
+ `^${passedRegex}${separator}${failedRegex}$`,
+ `^${failedRegex}${separator}${skippedRegex}$`,
+ `^${passedRegex}${separator}${skippedRegex}$`,
+ `^${passedRegex}${separator}${failedRegex}${separator}${skippedLabel}$`,
+ ]
+
+ return Joi.alternatives().try(
+ regexStrings.map(regexStr => Joi.string().regex(new RegExp(regexStr)))
+ )
+}
+
+const isDefaultAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
+ 'passed',
+ 'skipped',
+ 'failed'
+)
+const isCompactAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
+ '✔',
+ '✘',
+ '➟',
+ true
+)
+const isCustomAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
+ 'good',
+ 'bad',
+ 'n\\/a'
+)
+const isCompactCustomAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
+ '💃',
+ '🤦♀️',
+ '🤷',
+ true
+)
+
+t.create('unknown build definition')
+ .get(`${uriPrefix}/${nonExistentDefinitionId}.json`)
+ .expectJSON({ name: 'tests', value: 'build pipeline not found' })
+
+t.create('404 latest build error response')
+ .get(mockBadgeUri)
+ .intercept(nock =>
+ nock(azureDevOpsApiBaseUri)
+ .get(mockLatestBuildApiUriPath)
+ .reply(404)
+ )
+ .expectJSON({
+ name: 'tests',
+ value: 'build pipeline or test result summary not found',
+ })
+
+t.create('no build response')
+ .get(`${uriPrefix}/${nonExistentDefinitionId}.json`)
+ .intercept(nock =>
+ nock(azureDevOpsApiBaseUri)
+ .get(mockNonExistendBuildApiUriPath)
+ .reply(200, {
+ count: 0,
+ value: [],
+ })
+ )
+ .expectJSON({ name: 'tests', value: 'build pipeline not found' })
+
+t.create('no test result summary response')
+ .get(mockBadgeUri)
+ .intercept(nock =>
+ nock(azureDevOpsApiBaseUri)
+ .get(mockLatestBuildApiUriPath)
+ .reply(200, latestBuildResponse)
+ .get(mockTestResultSummaryApiUriPath)
+ .reply(404)
+ )
+ .expectJSON({
+ name: 'tests',
+ value: 'build pipeline or test result summary not found',
+ })
+
+t.create('invalid test result summary response')
+ .get(mockBadgeUri)
+ .intercept(nock =>
+ nock(azureDevOpsApiBaseUri)
+ .get(mockLatestBuildApiUriPath)
+ .reply(200, latestBuildResponse)
+ .get(mockTestResultSummaryApiUriPath)
+ .reply(200, {})
+ )
+ .expectJSON({ name: 'tests', value: 'invalid response data' })
+
+t.create('no tests in test result summary response')
+ .get(mockBadgeUri)
+ .intercept(nock =>
+ nock(azureDevOpsApiBaseUri)
+ .get(mockLatestBuildApiUriPath)
+ .reply(200, latestBuildResponse)
+ .get(mockTestResultSummaryApiUriPath)
+ .reply(200, mockEmptyTestResultSummaryResponse)
+ )
+ .expectJSON({ name: 'tests', value: 'no tests' })
+
+t.create('test status')
+ .get(mockBadgeUri)
+ .expectJSONTypes(
+ Joi.object().keys({ name: 'tests', value: isDefaultAzureDevOpsTestTotals })
+ )
+
+t.create('test status on branch')
+ .get(mockBranchBadgeUri)
+ .expectJSONTypes(
+ Joi.object().keys({ name: 'tests', value: isDefaultAzureDevOpsTestTotals })
+ )
+
+t.create('test status with compact message')
+ .get(mockBadgeUri, {
+ qs: {
+ compact_message: null,
+ },
+ })
+ .expectJSONTypes(
+ Joi.object().keys({ name: 'tests', value: isCompactAzureDevOpsTestTotals })
+ )
+
+t.create('test status with custom labels')
+ .get(mockBadgeUri, {
+ qs: {
+ passed_label: 'good',
+ failed_label: 'bad',
+ skipped_label: 'n/a',
+ },
+ })
+ .expectJSONTypes(
+ Joi.object().keys({ name: 'tests', value: isCustomAzureDevOpsTestTotals })
+ )
+
+t.create('test status with compact message and custom labels')
+ .get(mockBadgeUri, {
+ qs: {
+ compact_message: null,
+ passed_label: '💃',
+ failed_label: '🤦♀️',
+ skipped_label: '🤷',
+ },
+ })
+ .expectJSONTypes(
+ Joi.object().keys({
+ name: 'tests',
+ value: isCompactCustomAzureDevOpsTestTotals,
+ })
+ )