From e96a2c45cc9acde6fa4eb65970ecc975235ca425 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sat, 18 Jul 2026 11:15:00 +0200 Subject: [PATCH] Phase 3.2: migrate the results tab to azure-devops-extension-sdk Ports the build-results tab off the deprecated vss-web-extension-sdk (VSS.SDK.js + AMD module loader) to the supported azure-devops-extension-sdk v4 + azure-devops-extension-api v5. TypeScript, plain DOM, self-contained bundle. No React (not needed). The /mini viewer and behaviour are preserved. - info.ts / dialog.ts (was info.js / dialog.js): SDK.init/ready; build context via BuildPageDataService.getBuildPageData(); attachment listing via BuildRestClient.getAttachments + download from the attachment _links.self.href with SDK.getAccessToken(); TestingBot API via ServiceEndpointRestClient .executeServiceEndpointRequest; embed dialog via IHostPageLayoutService.openCustomDialog (config passed as { url }, read by the dialog through SDK.getConfiguration()). - Drop jQuery for plain document.createElement, and Buffer for btoa; the 180-line inline MD5 is replaced by the blueimp-md5 package. Keeps the Phase 3 pagination (stable page size, count=0 guard) and error-state fixes. - HTML now loads the webpack bundle directly ( - - + + + diff --git a/tb-build-info/infoTab.html b/tb-build-info/infoTab.html index f8ada21..cbab546 100644 --- a/tb-build-info/infoTab.html +++ b/tb-build-info/infoTab.html @@ -1,23 +1,17 @@ - + + TestingBot Info - - -
-

LOADING

+

Loading…

+ diff --git a/tb-build-info/scripts/dialog.js b/tb-build-info/scripts/dialog.js deleted file mode 100644 index 14fa9db..0000000 --- a/tb-build-info/scripts/dialog.js +++ /dev/null @@ -1,36 +0,0 @@ -/* global VSS */ -const getParams = query => { - if (!query) { - return { }; - } - - return (/^[?#]/.test(query) ? query.slice(1) : query) - .split('&') - .reduce((params, param) => { - let [key, value] = param.split('='); - params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : ''; - return params; - }, { }); -}; -// Only ever frame an https TestingBot URL. The url comes in as a query -// parameter, so validate it before assigning it as the iframe source to avoid -// framing an attacker-supplied page. -const isAllowedUrl = value => { - try { - const parsed = new URL(value); - return parsed.protocol === 'https:' && - (parsed.hostname === 'testingbot.com' || parsed.hostname.endsWith('.testingbot.com')); - } catch (err) { - return false; - } -}; - -const params = getParams(window.location.search); -if (isAllowedUrl(params.url)) { - var iframe = document.createElement('iframe'); - iframe.src = params.url; - iframe.style = 'width: 100%; min-height: 100vh; border: 0'; - document.body.appendChild(iframe); -} else { - document.body.textContent = 'Unable to display this test: the requested URL is not a valid TestingBot address.'; -} \ No newline at end of file diff --git a/tb-build-info/scripts/dialog.ts b/tb-build-info/scripts/dialog.ts new file mode 100644 index 0000000..f8c0fd0 --- /dev/null +++ b/tb-build-info/scripts/dialog.ts @@ -0,0 +1,37 @@ +import * as SDK from 'azure-devops-extension-sdk'; + +// Only ever frame an https TestingBot URL. The url arrives as dialog +// configuration from the host, so validate it before framing anything. +function isAllowedUrl(value: unknown): value is string { + if (typeof value !== 'string') { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' && + (parsed.hostname === 'testingbot.com' || parsed.hostname.endsWith('.testingbot.com')); + } catch { + return false; + } +} + +async function main(): Promise { + await SDK.init({ loaded: false }); + await SDK.ready(); + + const config = SDK.getConfiguration(); + const url = config && config.url; + + if (isAllowedUrl(url)) { + const iframe = document.createElement('iframe'); + iframe.src = url; + iframe.style.cssText = 'width: 100%; min-height: 100vh; border: 0'; + document.body.appendChild(iframe); + } else { + document.body.textContent = 'Unable to display this test: the requested URL is not a valid TestingBot address.'; + } + + SDK.notifyLoadSucceeded(); +} + +main(); diff --git a/tb-build-info/scripts/info.js b/tb-build-info/scripts/info.js deleted file mode 100644 index 6150fd4..0000000 --- a/tb-build-info/scripts/info.js +++ /dev/null @@ -1,331 +0,0 @@ -/* global VSS */ -const TaskAgentRestClient = require('TFS/DistributedTask/TaskAgentRestClient'); -const TaskRestClient = require('TFS/DistributedTask/TaskRestClient'); - -const webContext = VSS.getWebContext(); -const taskAgentRestClient = TaskAgentRestClient.getClient(); -const taskRestClient = TaskRestClient.getClient(); -const sharedConfig = VSS.getConfiguration(); - -function md5cycle(x, k) { - var a = x[0], b = x[1], c = x[2], d = x[3]; - - a = ff(a, b, c, d, k[0], 7, -680876936); - d = ff(d, a, b, c, k[1], 12, -389564586); - c = ff(c, d, a, b, k[2], 17, 606105819); - b = ff(b, c, d, a, k[3], 22, -1044525330); - a = ff(a, b, c, d, k[4], 7, -176418897); - d = ff(d, a, b, c, k[5], 12, 1200080426); - c = ff(c, d, a, b, k[6], 17, -1473231341); - b = ff(b, c, d, a, k[7], 22, -45705983); - a = ff(a, b, c, d, k[8], 7, 1770035416); - d = ff(d, a, b, c, k[9], 12, -1958414417); - c = ff(c, d, a, b, k[10], 17, -42063); - b = ff(b, c, d, a, k[11], 22, -1990404162); - a = ff(a, b, c, d, k[12], 7, 1804603682); - d = ff(d, a, b, c, k[13], 12, -40341101); - c = ff(c, d, a, b, k[14], 17, -1502002290); - b = ff(b, c, d, a, k[15], 22, 1236535329); - - a = gg(a, b, c, d, k[1], 5, -165796510); - d = gg(d, a, b, c, k[6], 9, -1069501632); - c = gg(c, d, a, b, k[11], 14, 643717713); - b = gg(b, c, d, a, k[0], 20, -373897302); - a = gg(a, b, c, d, k[5], 5, -701558691); - d = gg(d, a, b, c, k[10], 9, 38016083); - c = gg(c, d, a, b, k[15], 14, -660478335); - b = gg(b, c, d, a, k[4], 20, -405537848); - a = gg(a, b, c, d, k[9], 5, 568446438); - d = gg(d, a, b, c, k[14], 9, -1019803690); - c = gg(c, d, a, b, k[3], 14, -187363961); - b = gg(b, c, d, a, k[8], 20, 1163531501); - a = gg(a, b, c, d, k[13], 5, -1444681467); - d = gg(d, a, b, c, k[2], 9, -51403784); - c = gg(c, d, a, b, k[7], 14, 1735328473); - b = gg(b, c, d, a, k[12], 20, -1926607734); - - a = hh(a, b, c, d, k[5], 4, -378558); - d = hh(d, a, b, c, k[8], 11, -2022574463); - c = hh(c, d, a, b, k[11], 16, 1839030562); - b = hh(b, c, d, a, k[14], 23, -35309556); - a = hh(a, b, c, d, k[1], 4, -1530992060); - d = hh(d, a, b, c, k[4], 11, 1272893353); - c = hh(c, d, a, b, k[7], 16, -155497632); - b = hh(b, c, d, a, k[10], 23, -1094730640); - a = hh(a, b, c, d, k[13], 4, 681279174); - d = hh(d, a, b, c, k[0], 11, -358537222); - c = hh(c, d, a, b, k[3], 16, -722521979); - b = hh(b, c, d, a, k[6], 23, 76029189); - a = hh(a, b, c, d, k[9], 4, -640364487); - d = hh(d, a, b, c, k[12], 11, -421815835); - c = hh(c, d, a, b, k[15], 16, 530742520); - b = hh(b, c, d, a, k[2], 23, -995338651); - - a = ii(a, b, c, d, k[0], 6, -198630844); - d = ii(d, a, b, c, k[7], 10, 1126891415); - c = ii(c, d, a, b, k[14], 15, -1416354905); - b = ii(b, c, d, a, k[5], 21, -57434055); - a = ii(a, b, c, d, k[12], 6, 1700485571); - d = ii(d, a, b, c, k[3], 10, -1894986606); - c = ii(c, d, a, b, k[10], 15, -1051523); - b = ii(b, c, d, a, k[1], 21, -2054922799); - a = ii(a, b, c, d, k[8], 6, 1873313359); - d = ii(d, a, b, c, k[15], 10, -30611744); - c = ii(c, d, a, b, k[6], 15, -1560198380); - b = ii(b, c, d, a, k[13], 21, 1309151649); - a = ii(a, b, c, d, k[4], 6, -145523070); - d = ii(d, a, b, c, k[11], 10, -1120210379); - c = ii(c, d, a, b, k[2], 15, 718787259); - b = ii(b, c, d, a, k[9], 21, -343485551); - - x[0] = add32(a, x[0]); - x[1] = add32(b, x[1]); - x[2] = add32(c, x[2]); - x[3] = add32(d, x[3]); - - } - - function cmn(q, a, b, x, s, t) { - a = add32(add32(a, q), add32(x, t)); - return add32((a << s) | (a >>> (32 - s)), b); - } - - function ff(a, b, c, d, x, s, t) { - return cmn((b & c) | ((~b) & d), a, b, x, s, t); - } - - function gg(a, b, c, d, x, s, t) { - return cmn((b & d) | (c & (~d)), a, b, x, s, t); - } - - function hh(a, b, c, d, x, s, t) { - return cmn(b ^ c ^ d, a, b, x, s, t); - } - - function ii(a, b, c, d, x, s, t) { - return cmn(c ^ (b | (~d)), a, b, x, s, t); - } - - function md51(s) { - var n = s.length, - state = [1732584193, -271733879, -1732584194, 271733878], i; - for (i=64; i<=s.length; i+=64) { - md5cycle(state, md5blk(s.substring(i-64, i))); - } - s = s.substring(i-64); - var tail = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0]; - for (i=0; i>2] |= s.charCodeAt(i) << ((i%4) << 3); - tail[i>>2] |= 0x80 << ((i%4) << 3); - if (i > 55) { - md5cycle(state, tail); - for (i=0; i<16; i++) tail[i] = 0; - } - tail[14] = n*8; - md5cycle(state, tail); - return state; - } - - /* there needs to be support for Unicode here, - * unless we pretend that we can redefine the MD-5 - * algorithm for multi-byte characters (perhaps - * by adding every four 16-bit characters and - * shortening the sum to 32 bits). Otherwise - * I suggest performing MD-5 as if every character - * was two bytes--e.g., 0040 0025 = @%--but then - * how will an ordinary MD-5 sum be matched? - * There is no way to standardize text to something - * like UTF-8 before transformation; speed cost is - * utterly prohibitive. The JavaScript standard - * itself needs to look at this: it should start - * providing access to strings as preformed UTF-8 - * 8-bit unsigned value arrays. - */ - function md5blk(s) { /* I figured global was faster. */ - var md5blks = [], i; /* Andy King said do it this way. */ - for (i=0; i<64; i+=4) { - md5blks[i>>2] = s.charCodeAt(i) - + (s.charCodeAt(i+1) << 8) - + (s.charCodeAt(i+2) << 16) - + (s.charCodeAt(i+3) << 24); - } - return md5blks; - } - - var hex_chr = '0123456789abcdef'.split(''); - - function rhex(n) - { - var s='', j=0; - for(; j<4; j++) - s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] - + hex_chr[(n >> (j * 8)) & 0x0F]; - return s; - } - - function hex(x) { - for (var i=0; i> 16) + (y >> 16) + (lsw >> 16); - return (msw << 16) | (lsw & 0xFFFF); - } - } - -const getBuildResponse = async function (buildInformation, offset) { - const json = await taskAgentRestClient.executeServiceEndpointRequest({ - 'dataSourceDetails': { - dataSourceName: 'getBuildFullJobs', - headers: { - name: 'Authorization', - value: 'Basic ' + Buffer.from(buildInformation.TB_KEY + ':' + buildInformation.TB_SECRET).toString('base64'), - }, - parameters: { - build: buildInformation.TB_BUILD_NAME, - offset: offset, - } - } - }, - webContext.project.id, - buildInformation.CONNECTED_SERVICE_NAME - ) - - return JSON.parse(json.result[0]); -} - -const renderError = function (message) { - const $buildinfo = $('.build-info').empty(); - $buildinfo.append($('

').text(message)); -}; - -// pageSize is the stable number of results per API page, captured once from the -// first (offset 0) response. Using the *current* page's count as the divisor -// corrupts the page links on a partial last page, and a count of 0 would make -// the pagination loop spin forever. -const renderResults = function (buildInformation, buildFullJobs, buildFullMeta, currentOffset, pageSize) { - const $table = $(''); - $table.css('min-width', '800px'); - $table.append(''); - - const $footer = $(''); - $tr.append($(''; + const tbody = document.createElement('tbody'); + + jobs.forEach((job) => { + const tr = document.createElement('tr'); + + const nameCell = document.createElement('td'); + const link = document.createElement('a'); + link.href = '#'; + link.textContent = job.name; + link.addEventListener('click', (e) => { + e.preventDefault(); + openTestDialog(buildInformation, job).catch((err) => console.error('dialog error', err)); + }); + nameCell.appendChild(link); + tr.appendChild(nameCell); + + const envCell = document.createElement('td'); + envCell.textContent = `${job.os} ${job.browser}`; + tr.appendChild(envCell); + + const resultCell = document.createElement('td'); + resultCell.textContent = job.success ? 'Passed' : 'Failed'; + tr.appendChild(resultCell); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + el.appendChild(table); + + el.appendChild(renderPagination(buildInformation, meta, currentOffset, pageSize)); +} + +// pageSize is captured once from the first response; using the current page's +// count would corrupt links on a partial last page, and a count of 0 would spin. +function renderPagination( + buildInformation: BuildInformation, + meta: BuildMeta, + currentOffset: number, + pageSize: number +): HTMLElement { + const list = document.createElement('ul'); + list.style.paddingLeft = '0'; + if (!(pageSize > 0 && meta && meta.total > pageSize)) { + return list; + } + + for (let offset = 0; offset < meta.total; offset += pageSize) { + const pageNumber = offset / pageSize + 1; + const item = document.createElement('li'); + item.style.display = 'inline'; + item.style.marginRight = '8px'; + + if (offset === currentOffset) { + item.textContent = String(pageNumber); + } else { + const link = document.createElement('a'); + link.href = '#'; + link.textContent = String(pageNumber); + const targetOffset = offset; + link.addEventListener('click', async (e) => { + e.preventDefault(); + try { + const response = await getBuildResponse(buildInformation, targetOffset); + renderResults(buildInformation, response.data, response.meta, targetOffset, pageSize); + } catch (err) { + console.error('error loading page', err); + renderMessage('Could not load TestingBot results for this page.'); + } + }); + item.appendChild(link); + } + list.appendChild(item); + } + return list; +} + +async function load(): Promise { + const buildPageService = await SDK.getService(BuildServiceIds.BuildPageDataService); + const build = buildPageService.getBuildPageData()?.build; + if (!build) { + renderMessage('No test results found'); + return; + } + + const attachments = await buildClient.getAttachments(projectId, build.id, ATTACHMENT_TYPE); + if (!attachments.length) { + renderMessage('No test results found'); + return; + } + + const href = attachments[0]._links?.self?.href; + if (!href) { + renderMessage('No test results found'); + return; + } + + const buildInformation = await downloadAttachmentJson(href); + const response = await getBuildResponse(buildInformation, 0); + const pageSize = response.meta ? response.meta.count : 0; + renderResults(buildInformation, response.data, response.meta, 0, pageSize); +} + +async function main(): Promise { + await SDK.init({ loaded: false }); + await SDK.ready(); + + buildClient = getClient(BuildRestClient); + endpointClient = getClient(ServiceEndpointRestClient); + projectId = SDK.getWebContext().project.id; + + try { + await load(); + } catch (err) { + console.error('error', err); + renderMessage('Could not load TestingBot results.'); + } finally { + SDK.notifyLoadSucceeded(); + } +} + +main(); diff --git a/tb-build-info/tsconfig.json b/tb-build-info/tsconfig.json new file mode 100644 index 0000000..8c4fe5b --- /dev/null +++ b/tb-build-info/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "lib": ["ES2020", "DOM"], + "types": [], + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["scripts/**/*.ts"] +} diff --git a/vss-extension.json b/vss-extension.json index fa7158e..b5bf10e 100644 --- a/vss-extension.json +++ b/vss-extension.json @@ -28,8 +28,7 @@ { "path": "images", "addressable": true }, { "path": "tb-main" }, { "path": "tb-stop-tunnel" }, - { "path": "tb-build-info", "addressable": true }, - { "path": "lib", "addressable": true } + { "path": "tb-build-info", "addressable": true } ], "scopes": [ "vso.build", "vso.serviceendpoint_query" ], @@ -92,7 +91,7 @@ "description": "Embed Dialog", "targets": [], "properties": { - "uri": "tb-build-info/embedDialog.html?url={{url}}" + "uri": "tb-build-info/embedDialog.html" } } ] diff --git a/webpack.config.js b/webpack.config.js index 6abc8e6..b57ebd0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,26 +1,9 @@ const path = require('path'); -const webpack = require('webpack'); const CopyPlugin = require('copy-webpack-plugin'); -// Host-provided AMD modules loaded by the VSS module loader at runtime. They -// must stay external so webpack does not try to bundle them. -const vssExternals = [ - 'TFS/Build/Contracts', - 'TFS/Build/ExtensionContracts', - 'TFS/Build/RestClient', - 'TFS/DistributedTask/TaskAgentRestClient', - 'TFS/DistributedTask/TaskRestClient', - 'TFS/DistributedTask/TaskAgentHttpClient', - 'VSS/Authentication/Services', - 'VSS/Controls', - 'VSS/Service', - 'react', - 'React' -]; - -// The tasks are TypeScript: tsc emits index.js in-place, which this copy step -// picks up. The .ts sources, tests, tsconfig and dev node_modules are excluded; -// the production node_modules are installed into dist/ afterwards. +// The agent tasks are TypeScript compiled in-place by tsc; this copy step picks +// up the emitted index.js. The .ts sources, tests, tsconfig and dev node_modules +// are excluded; the production node_modules are installed into dist/ afterwards. const taskIgnore = [ '**/node_modules/**', '**/.DS_Store', @@ -33,36 +16,32 @@ const taskIgnore = [ module.exports = { target: 'web', entry: { - info: './tb-build-info/scripts/info.js', - dialog: './tb-build-info/scripts/dialog.js' + info: './tb-build-info/scripts/info.ts', + dialog: './tb-build-info/scripts/dialog.ts' }, output: { path: path.resolve(__dirname, 'dist'), filename: 'tb-build-info/scripts/[name].js', - library: { type: 'amd' }, clean: false }, - externalsType: 'amd', - externals: vssExternals, + resolve: { + extensions: ['.ts', '.js'] + }, module: { rules: [ { - test: /\.js$/, + test: /\.ts$/, exclude: /node_modules/, use: { loader: 'babel-loader', - options: { presets: ['@babel/preset-env'] } + options: { + presets: ['@babel/preset-env', '@babel/preset-typescript'] + } } } ] }, - resolve: { - // info.js still constructs a Basic auth header with Buffer; the browser has - // no Buffer, so provide the polyfill until the tab is rewritten (Phase 3). - fallback: { buffer: require.resolve('buffer/') } - }, plugins: [ - new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), new CopyPlugin({ patterns: [ { from: 'images', to: 'images' }, @@ -83,10 +62,6 @@ module.exports = { from: 'tb-build-info', to: 'tb-build-info', globOptions: { ignore: ['**/scripts/**', '**/.DS_Store'] } - }, - { - from: 'node_modules/vss-web-extension-sdk/lib/VSS.SDK.js', - to: 'lib/VSS.SDK.js' } ] })
Test NameOS/BrowserPass/Fail
').append( - $('') - .attr('href', '#') - .text(job.name) - .click(function(e) { - e.preventDefault(); - var dialogOptions = { - title: 'Test Information', - width: 1000, - height: 700, - urlReplacementObject: { - url: encodeURIComponent('https://testingbot.com/mini/' + job.session_id + '?auth=' + auth) - } - }; - VSS.getService(VSS.ServiceIds.Dialog).then(function(dialogService) { - var extensionCtx = VSS.getExtensionContext(); - var contributionId = extensionCtx.publisherId + '.' + extensionCtx.extensionId + '.embed-dialog'; - dialogService.openDialog(contributionId, dialogOptions); - }); - }) - )); - $tr.append($('').text(job.os + ' ' + job.browser)); - $tr.append($('').text(job.success ? "Passed" : "Failed")); - $table.append($tr); - }); - - const $buildinfo = $('.build-info').empty(); - $buildinfo.css('height','400px'); - $buildinfo.css('overflow','auto'); - $buildinfo.append('

TestingBot results (' + buildFullMeta.total + ' tests)

').append($table).append($footer); -} - -sharedConfig.onBuildChanged(async function(build) { - try { - if (!build || !build.orchestrationPlan) { - renderError('No test results found'); - return; - } - - const taskAttachments = await taskRestClient.getPlanAttachments( - webContext.project.id, - 'build', - build.orchestrationPlan.planId, - 'TestingBotBuildResult' - ); - - if (taskAttachments.length < 1) { - renderError('No test results found'); - return; - } - const buildInformation = await taskRestClient.getAttachmentContent( - webContext.project.id, - 'build', - build.orchestrationPlan.planId, - taskAttachments[0].timelineId, - taskAttachments[0].recordId, - taskAttachments[0].type, - taskAttachments[0].name - ) - .then(arrayBuffer => new window.TextDecoder('UTF-8').decode(arrayBuffer)) - .then(str => JSON.parse(str)); - - const buildFullResponse = await getBuildResponse(buildInformation, 0) - const buildFullJobs = buildFullResponse.data; - const buildFullMeta = buildFullResponse.meta; - - // Capture the page size once, from the first response, and thread it through - // so pagination stays stable regardless of the current page's count. - const pageSize = buildFullMeta ? buildFullMeta.count : 0; - renderResults(buildInformation, buildFullJobs, buildFullMeta, 0, pageSize) - } catch (err) { - console.error('error', err); - renderError('Could not load TestingBot results.'); - } -}); \ No newline at end of file diff --git a/tb-build-info/scripts/info.ts b/tb-build-info/scripts/info.ts new file mode 100644 index 0000000..cbc2a46 --- /dev/null +++ b/tb-build-info/scripts/info.ts @@ -0,0 +1,241 @@ +import * as SDK from 'azure-devops-extension-sdk'; +import { getClient, CommonServiceIds, IHostPageLayoutService } from 'azure-devops-extension-api/Common'; +import { BuildRestClient, BuildServiceIds, IBuildPageDataService } from 'azure-devops-extension-api/Build'; +import { ServiceEndpointRestClient, ServiceEndpointRequest } from 'azure-devops-extension-api/ServiceEndpoint'; +import md5 from 'blueimp-md5'; + +interface BuildInformation { + TB_KEY: string; + TB_SECRET: string; + TB_BUILD_NAME: string; + CONNECTED_SERVICE_NAME: string; +} + +interface TestJob { + session_id: string; + name: string; + os: string; + browser: string; + success: boolean; +} + +interface BuildMeta { + offset: number; + count: number; + total: number; +} + +interface BuildResponse { + data: TestJob[]; + meta: BuildMeta; +} + +const ATTACHMENT_TYPE = 'TestingBotBuildResult'; + +let buildClient: BuildRestClient; +let endpointClient: ServiceEndpointRestClient; +let projectId: string; + +function container(): HTMLElement { + return document.querySelector('.build-info') as HTMLElement; +} + +function renderMessage(message: string): void { + const el = container(); + el.textContent = ''; + const h2 = document.createElement('h2'); + h2.textContent = message; + el.appendChild(h2); +} + +async function downloadAttachmentJson(href: string): Promise { + // getAttachments only returns the name + _links; download the content from the + // attachment's self link, authorized with the extension's access token. + const token = await SDK.getAccessToken(); + const response = await fetch(href, { headers: { Authorization: `Bearer ${token}` } }); + if (!response.ok) { + throw new Error(`Attachment download failed (${response.status})`); + } + return JSON.parse(await response.text()); +} + +async function getBuildResponse(buildInformation: BuildInformation, offset: number): Promise { + const headers = []; + // The TestingBot data source injects auth server-side; the Basic header is only + // added for backwards compatibility when the (legacy) secret is present. + if (buildInformation.TB_SECRET) { + headers.push({ + name: 'Authorization', + value: 'Basic ' + btoa(buildInformation.TB_KEY + ':' + buildInformation.TB_SECRET) + }); + } + + const request = { + dataSourceDetails: { + dataSourceName: 'getBuildFullJobs', + dataSourceUrl: '', + headers, + parameters: { build: buildInformation.TB_BUILD_NAME, offset: String(offset) } + } + } as unknown as ServiceEndpointRequest; + + const result = await endpointClient.executeServiceEndpointRequest( + request, + projectId, + buildInformation.CONNECTED_SERVICE_NAME + ); + + return JSON.parse((result.result as string[])[0]); +} + +async function openTestDialog(buildInformation: BuildInformation, job: TestJob): Promise { + const auth = md5(buildInformation.TB_KEY + ':' + buildInformation.TB_SECRET + ':' + job.session_id); + const url = 'https://testingbot.com/mini/' + job.session_id + '?auth=' + auth; + const ctx = SDK.getExtensionContext(); + const layoutService = await SDK.getService(CommonServiceIds.HostPageLayoutService); + layoutService.openCustomDialog(`${ctx.publisherId}.${ctx.extensionId}.embed-dialog`, { + title: 'Test Information', + configuration: { url } + }); +} + +function renderResults( + buildInformation: BuildInformation, + jobs: TestJob[], + meta: BuildMeta, + currentOffset: number, + pageSize: number +): void { + const el = container(); + el.textContent = ''; + el.style.height = '400px'; + el.style.overflow = 'auto'; + + const heading = document.createElement('h2'); + heading.textContent = `TestingBot results (${meta.total} tests)`; + el.appendChild(heading); + + const table = document.createElement('table'); + table.style.minWidth = '800px'; + table.innerHTML = '
Test NameOS/BrowserPass/Fail