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: +

+ORGANIZATION is after the dev.azure.com part, PROJECT is right after that, DEFINITION_ID is at the end after the id= part. +

+ 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, + }) + )