diff --git a/.vscode/launch.json b/.vscode/launch.json index 4dc9a95..01a5c45 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,10 @@ "INPUT_REPORTDIR": "${workspaceFolder}/tasks/UploadPortalHtmlReport/tests/fixtures", "INPUT_TABNAME": "HTML Report", "INPUT_REDACTSECRETS": "false", - "INPUT_FAILONEMPTY": "true" + "INPUT_FAILONEMPTY": "true", + "INPUT_INLINEASSETS": "true", + "INPUT_PUBLISHARCHIVE": "true", + "INPUT_FAILONFAILEDREPORTS": "false" }, "skipFiles": [ "/**" diff --git a/README.md b/README.md index 5dc9df5..fc5ec5a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Azure DevOps HTML Report Portal -Publish self-contained HTML reports (Newman HTML Extra, Playwright, Cypress, coverage, or any other HTML file) and view them as a tab on Azure Pipelines build and release results. +Publish HTML reports and view them as a tab on Azure Pipelines build and release results. -Each tab embeds the report in the pipeline UI and provides a download link. +Each tab embeds the report in the pipeline UI, with per-report download and an optional zip of the whole folder. This project is a fork of [maciejmaciejewski/azure-pipelines-postman](https://github.com/maciejmaciejewski/azure-pipelines-postman), generalized beyond Postman reports. @@ -10,14 +10,17 @@ This project is a fork of [maciejmaciejewski/azure-pipelines-postman](https://gi Add the **Upload HTML Report** task after your tests produce HTML output. Use `condition: succeededOrFailed()` so reports still publish when tests fail. -The task takes: +| Input | Default | Description | +| --- | --- | --- | +| `reportDir` | `$(System.DefaultWorkingDirectory)` | A single `.html`/`.htm` file, or a directory searched recursively | +| `tabName` | `HTML Report` | Tab label on the pipeline run | +| `inlineAssets` | `true` | Embed local CSS, JS, and images so multi-file reports (coverage, etc.) render in the tab | +| `publishArchive` | `true` | Attach a zip of a report **directory** (skipped above 50 MB; `node_modules` is omitted) | +| `redactSecrets` | `false` | Mask Bearer tokens and common secret keys (useful for Newman HTML Extra) | +| `failOnEmpty` | `true` | Fail when no HTML files are found | +| `failOnFailedReports` | `false` | Fail after upload when a report looks unsuccessful (Newman failed tests or Playwright `unexpected` count) | -- `reportDir` (required) — a single `.html`/`.htm` file, or a directory that is searched recursively -- `tabName` (optional) — tab label on the pipeline run (default: `HTML Report`) -- `redactSecrets` (optional) — mask Bearer tokens and common secret keys before upload (default: `false`) -- `failOnEmpty` (optional) — fail the task when no HTML files are found (default: `true`) - -Reports should be **self-contained** HTML (CSS/JS inlined). Companion assets such as Playwright's `playwright-report/` folder are not published as a static site. +`index.html` is listed first when a directory contains several HTML files. A single report is expanded automatically in the tab. ```yaml steps: @@ -39,16 +42,19 @@ steps: reportDir: '$(System.DefaultWorkingDirectory)/reports/newman.html' tabName: 'Postman' redactSecrets: true + failOnFailedReports: true ``` -### Directory of reports +### Coverage or other multi-file HTML + +Local `link`, `script`, and `img` references are inlined into each HTML file before upload. That is enough for typical coverage folders (JaCoCo, Istanbul). Playwright/Cypress apps that `fetch()` extra JSON at runtime still need a self-contained HTML export, or use **Download all** for the original folder. ```yaml - task: UploadPortalHtmlReport@1 condition: succeededOrFailed() inputs: - reportDir: '$(System.DefaultWorkingDirectory)/reports' - tabName: 'QA Reports' + reportDir: '$(System.DefaultWorkingDirectory)/coverage' + tabName: 'Coverage' ``` Run the task more than once with different `tabName` values to publish multiple report groups. diff --git a/azure-devops-extension.json b/azure-devops-extension.json index f5a99c9..5b18045 100644 --- a/azure-devops-extension.json +++ b/azure-devops-extension.json @@ -5,7 +5,7 @@ "publisher": "joneja09", "public": false, "author": "Jeff Jones", - "version": "1.2.0", + "version": "1.3.0", "description": "Embed HTML reports in Azure Pipelines", "galleryFlags": [], "repository": { diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 319034b..a6c3a12 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -59,7 +59,7 @@ steps: extensionId: "html-report-portal" extensionTag: dev extensionName: "HTML Report Portal Dev" - extensionVersion: "1.2.$(Build.BuildId)" + extensionVersion: "1.3.$(Build.BuildId)" updateTasksVersion: true extensionVisibility: private extensionPricing: free diff --git a/overview.md b/overview.md index 39d2f34..49231f0 100644 --- a/overview.md +++ b/overview.md @@ -1,8 +1,8 @@ # HTML Report Portal -Publish self-contained HTML reports and view them as a tab on Azure Pipelines build and release results. Each tab embeds the report and provides a download link. +Publish HTML reports and view them as a tab on Azure Pipelines build and release results. Each tab embeds the report, offers a download, and can attach a zip of the original folder. -Use this for Newman HTML Extra, Playwright, Cypress, coverage, or any other self-contained HTML file. +Local CSS, JavaScript, and images are inlined by default so coverage-style folders render in the tab. For full documentation see [GitHub](https://github.com/joneja09/azure-pipelines-html-viewer). @@ -10,11 +10,6 @@ For full documentation see [GitHub](https://github.com/joneja09/azure-pipelines- Add the **Upload HTML Report** task after your tests produce HTML output. Use `condition: succeededOrFailed()` so reports still publish when tests fail. -- `reportDir` (required): a single `.html`/`.htm` file, or a directory searched recursively -- `tabName` (optional): tab label on the pipeline run -- `redactSecrets` (optional): mask Bearer tokens and common secret keys (useful for Postman/Newman reports) -- `failOnEmpty` (optional): fail when no HTML files are found - ```yaml steps: - task: UploadPortalHtmlReport@1 diff --git a/package.json b/package.json index 7e05f06..85d04f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-pipelines-html-portal", - "version": "1.2.0", + "version": "1.3.0", "description": "Embed HTML reports into Azure Pipelines build and release tabs", "main": "index.js", "scripts": { diff --git a/src/tabContent.scss b/src/tabContent.scss index fef5ac7..574a13b 100644 --- a/src/tabContent.scss +++ b/src/tabContent.scss @@ -24,3 +24,9 @@ iframe.full-size { height: calc(100vh - 180px); min-height: 640px; } + +.archive-bar { + display: flex; + justify-content: flex-end; + margin: 0 0 12px 0; +} diff --git a/src/tabContent.tsx b/src/tabContent.tsx index 3fe299b..381bbf4 100644 --- a/src/tabContent.tsx +++ b/src/tabContent.tsx @@ -13,10 +13,12 @@ import { ObservableValue, ObservableObject } from "azure-devops-ui/Core/Observab import { Observer } from "azure-devops-ui/Observer" import { Tab, TabBar, TabSize } from "azure-devops-ui/Tabs" import { Card } from "azure-devops-ui/Card" +import { Button } from "azure-devops-ui/Button" import { IHeaderCommandBarItem } from "azure-devops-ui/HeaderCommandBar" const ATTACHMENT_TYPE = "portal.summary" const REPORT_ATTACHMENT_TYPE = "portal.report" +const ARCHIVE_ATTACHMENT_TYPE = "portal.archive" const OUR_TASK_IDS = [ "4d9a74ab-346a-4549-936a-6a3d3ad77227" ] @@ -50,10 +52,34 @@ function parseAttachmentName(name: string): AttachmentNameParts { } } +function parseSummaryPayload(payload: any): { reports: any[], archive: any } { + if (Array.isArray(payload)) { + return { reports: payload, archive: null } + } + if (payload && typeof payload === "object") { + return { + reports: payload.reports || [], + archive: payload.archive || null + } + } + return { reports: [], archive: null } +} + function toBase64(value: string): string { return btoa(value) } +function triggerBlobDownload(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + SDK.init() SDK.ready().then(() => { try { @@ -140,9 +166,21 @@ interface ReportProps { href: string } +interface ArchiveProps { + name: string + fileName: string + href: string +} + +interface SummaryResult { + reports: ReportProps[] + archive: ArchiveProps +} + interface ReportCardProps { attachmentClient: AttachmentClient report: ReportProps + startExpanded?: boolean } class ReportCard extends React.Component { @@ -150,9 +188,11 @@ class ReportCard extends React.Component { private initialContent = "

Loading...

" private content = new ObservableValue(this.initialContent) private commandBarItems: IHeaderCommandBarItem[] + private objectUrl: string = null constructor(props: ReportCardProps) { super(props) + this.collapsed = new ObservableValue(!props.startExpanded) this.commandBarItems = [ { important: true, @@ -166,14 +206,16 @@ class ReportCard extends React.Component { ] } - private escapeHTML(str: string) { - return str.replace(/[&<>'"]/g, (tag) => ({ - "&": "&", - "<": "<", - ">": ">", - "'": "'", - '"': """ - }[tag] || tag)) + public componentDidMount() { + if (this.props.startExpanded) { + this.loadReport() + } + } + + public componentWillUnmount() { + if (this.objectUrl) { + URL.revokeObjectURL(this.objectUrl) + } } public render() { @@ -200,28 +242,32 @@ class ReportCard extends React.Component { private downloadReport = () => { this.props.attachmentClient.download(this.props.report.href).then((report) => { - const blob = new Blob([report], { type: "text/html" }) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.download = this.props.report.fileName || "report.html" - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) + triggerBlobDownload(new Blob([report], { type: "text/html" }), this.props.report.fileName || "report.html") }).catch((err) => { console.error(err) }) } + private loadReport = () => { + if (this.content.value != this.initialContent) { + return + } + this.props.attachmentClient.download(this.props.report.href).then((report) => { + if (this.objectUrl) { + URL.revokeObjectURL(this.objectUrl) + } + const blob = new Blob([report], { type: "text/html" }) + this.objectUrl = URL.createObjectURL(blob) + this.content.value = '' + }).catch((err) => { + this.content.value = "

" + String(err).replace(/[&<>'"]/g, "") + "

" + }) + } + private onCollapseClicked = () => { this.collapsed.value = !this.collapsed.value - if (this.content.value == this.initialContent) { - this.props.attachmentClient.download(this.props.report.href).then((report) => { - this.content.value = '' - }).catch((err) => { - this.content.value = "

" + this.escapeHTML(String(err)) + "

" - }) + if (!this.collapsed.value) { + this.loadReport() } } } @@ -276,11 +322,29 @@ export default class TaskAttachmentPanel extends React.Component { const cards = [] - for (const reportData of summary) { - const cardProps: ReportCardProps = { report: reportData, attachmentClient: this.props.attachmentClient } + const startExpanded = summary.reports.length === 1 + for (const reportData of summary.reports) { + const cardProps: ReportCardProps = { + report: reportData, + attachmentClient: this.props.attachmentClient, + startExpanded + } cards.push() } - const content =
{cards}
+ const content = ( +
+ {summary.archive ? +
+
+ : null} + {cards} +
+ ) this.tabContents.set(props.selectedTabId, content) }).catch((error) => { this.tabContents.set(props.selectedTabId,

Error loading report:
{String(error)}

) @@ -295,6 +359,14 @@ export default class TaskAttachmentPanel extends React.Component { + this.props.attachmentClient.downloadBinary(archive.href).then((blob) => { + triggerBlobDownload(blob, archive.fileName || "html-reports.zip") + }).catch((err) => { + console.error(err) + }) + } + private onSelectedTabChanged = (newTabId: string) => { this.selectedTabId.value = newTabId } @@ -305,6 +377,7 @@ abstract class AttachmentClient { protected authHeaders: { [header: string]: string } = undefined abstract async init(): Promise + abstract async getAttachmentsOfType(type: string): Promise<(Attachment | ReleaseTaskAttachment)[]> public getAttachments(): (Attachment | ReleaseTaskAttachment)[] { return this.attachments @@ -326,6 +399,14 @@ abstract class AttachmentClient { return await response.text() } + public async downloadBinary(href: string): Promise { + const response = await fetch(href, { headers: await this.getAuthHeaders() }) + if (!response.ok) { + throw new Error(response.statusText) + } + return await response.blob() + } + public getDownloadableAttachment(attachmentName: string): Attachment | ReleaseTaskAttachment { const attachment = this.attachments.find((item) => item.name === attachmentName) if (!(attachment && attachment._links && attachment._links.self && attachment._links.self.href)) { @@ -334,15 +415,18 @@ abstract class AttachmentClient { return attachment } - abstract async getReportAttachments(): Promise<(Attachment | ReleaseTaskAttachment)[]> + public async getReportAttachments(): Promise<(Attachment | ReleaseTaskAttachment)[]> { + return this.getAttachmentsOfType(REPORT_ATTACHMENT_TYPE) + } - public async getReportSummary(attachmentName: string): Promise { + public async getReportSummary(attachmentName: string): Promise { setText("Looking for Summary File") const attachment = this.getDownloadableAttachment(attachmentName) - const summaryContentJson = JSON.parse(await this.download(attachment._links.self.href)) + const payload = parseSummaryPayload(JSON.parse(await this.download(attachment._links.self.href))) setText("Processing Summary File") - const reports = await this.getReportAttachments() - return summaryContentJson.map((report) => { + const reports = await this.getAttachmentsOfType(REPORT_ATTACHMENT_TYPE) + const archives = await this.getAttachmentsOfType(ARCHIVE_ATTACHMENT_TYPE) + const mappedReports = payload.reports.map((report) => { const rp = reports.find((item) => item.name === report.name) const parsed = parseAttachmentName(report.name) const href = rp && rp._links && rp._links.self && rp._links.self.href @@ -353,6 +437,20 @@ abstract class AttachmentClient { href } }).filter((report) => !!report.href) + + let archive: ArchiveProps = null + if (payload.archive && payload.archive.name) { + const match = archives.find((item) => item.name === payload.archive.name) + if (match && match._links && match._links.self && match._links.self.href) { + archive = { + name: payload.archive.name, + fileName: payload.archive.fileName || "html-reports.zip", + href: match._links.self.href + } + } + } + + return { reports: mappedReports, archive } } } @@ -369,9 +467,9 @@ class BuildAttachmentClient extends AttachmentClient { this.attachments = await buildClient.getAttachments(this.build.project.id, this.build.id, ATTACHMENT_TYPE) } - public async getReportAttachments(): Promise { + public async getAttachmentsOfType(type: string): Promise { const buildClient: BuildRestClient = getClient(BuildRestClient) - return await buildClient.getAttachments(this.build.project.id, this.build.id, REPORT_ATTACHMENT_TYPE) + return await buildClient.getAttachments(this.build.project.id, this.build.id, type) } } @@ -445,9 +543,9 @@ class ReleaseAttachmentClient extends AttachmentClient { } } - public async getReportAttachments(): Promise { + public async getAttachmentsOfType(type: string): Promise { const releaseClient: ReleaseRestClient = getClient(ReleaseRestClient) - let reports: ReleaseTaskAttachment[] = [] + let results: ReleaseTaskAttachment[] = [] for (const planId of this.runPlanIds) { const planReports = await releaseClient.getReleaseTaskAttachments( this.projectId, @@ -455,10 +553,10 @@ class ReleaseAttachmentClient extends AttachmentClient { this.releaseEnvironment.id, this.deployStepAttempt, planId, - REPORT_ATTACHMENT_TYPE + type ) - reports = reports.concat(planReports) + results = results.concat(planReports) } - return reports + return results } } diff --git a/tasks/UploadPortalHtmlReport/index.js b/tasks/UploadPortalHtmlReport/index.js index 624b51c..59203d6 100644 --- a/tasks/UploadPortalHtmlReport/index.js +++ b/tasks/UploadPortalHtmlReport/index.js @@ -1,5 +1,5 @@ const tl = require("azure-pipelines-task-lib/task") -const { resolve, join } = require("path") +const { resolve, join, dirname } = require("path") const { readFileSync, writeFileSync, mkdirSync, statSync } = require("fs") const { tmpdir } = require("os") const { @@ -7,11 +7,14 @@ const { displayNameFor, generateAttachmentName, isReportSuccessful, - redactHtmlDocument + redactHtmlDocument, + inlineLocalAssets, + createReportArchive } = require("./lib") const REPORT_TYPE = "portal.report" const SUMMARY_TYPE = "portal.summary" +const ARCHIVE_TYPE = "portal.archive" const MAX_REDACT_BYTES = 5 * 1024 * 1024 function getTempWorkDir() { @@ -25,14 +28,24 @@ function uniqueOutputPath(workDir, relativeName) { return join(workDir, relativeName.replace(/[\\/]/g, "_")) } -function run() { +function getBool(name, defaultValue) { + const raw = tl.getInput(name, false) + if (raw === undefined || raw === null || raw === "") { + return defaultValue + } + return tl.getBoolInput(name, false) +} + +async function run() { const reportDir = resolve(tl.getPathInput("reportDir", true, false)) const tabName = tl.getInput("tabName", false) || "HTML Report" - const redactSecrets = tl.getBoolInput("redactSecrets", false) - const failOnEmpty = tl.getBoolInput("failOnEmpty", false) - - statSync(reportDir) + const redactSecrets = getBool("redactSecrets", false) + const failOnEmpty = getBool("failOnEmpty", true) + const failOnFailedReports = getBool("failOnFailedReports", false) + const inlineAssets = getBool("inlineAssets", true) + const publishArchive = getBool("publishArchive", true) + const reportStats = statSync(reportDir) const files = findHtmlFiles(reportDir) if (files.length === 0) { const message = `No HTML files found in ${reportDir}` @@ -53,9 +66,19 @@ function run() { files.forEach((file) => { const relativeName = displayNameFor(file, reportDir) tl.debug(`Reading report ${file}`) - const fileContent = readFileSync(file, "utf8") - let outputContent = fileContent + let fileContent = readFileSync(file, "utf8") + + if (inlineAssets) { + const result = inlineLocalAssets(fileContent, file, reportStats.isDirectory() ? reportDir : dirname(file)) + result.warnings.forEach((warning) => tl.warning(warning)) + if (result.inlined.length) { + tl.debug(`Inlined ${result.inlined.length} asset(s) into ${relativeName}`) + } + fileContent = result.html + } + const successful = isReportSuccessful(fileContent) + let outputContent = fileContent if (redactSecrets) { const bytes = Buffer.byteLength(fileContent, "utf8") @@ -91,6 +114,30 @@ function run() { tl.debug(`Uploaded ${relativeName} as ${attachmentName}`) }) + let archiveInfo = null + if (publishArchive && reportStats.isDirectory()) { + const archivePath = join(workDir, "html-reports.zip") + const archiveResult = await createReportArchive(reportDir, archivePath) + if (archiveResult.skipped) { + tl.warning(`Skipped report zip (${archiveResult.reason})`) + } else { + const archiveName = generateAttachmentName({ + tabName, + jobName, + stageName, + stageAttempt, + fileName: "html-reports.zip" + }) + tl.addAttachment(ARCHIVE_TYPE, archiveName, archivePath) + archiveInfo = { + name: archiveName, + type: ARCHIVE_TYPE, + fileName: "html-reports.zip" + } + console.log(`Published zip archive (${archiveResult.files} files, ${archiveResult.bytes} bytes)`) + } + } + const summaryPath = join(workDir, "summary.json") const summaryName = generateAttachmentName({ tabName, @@ -99,17 +146,28 @@ function run() { stageAttempt, fileName: "summary.json" }) - writeFileSync(summaryPath, JSON.stringify(fileProperties, null, 2)) + const summaryPayload = { + version: 2, + reports: fileProperties, + archive: archiveInfo + } + writeFileSync(summaryPath, JSON.stringify(summaryPayload, null, 2)) tl.addAttachment(SUMMARY_TYPE, summaryName, summaryPath) console.log(`Published ${fileProperties.length} HTML report(s) to tab "${tabName}"`) + + const failedReports = fileProperties.filter((item) => item.successful === false) + if (failOnFailedReports && failedReports.length) { + tl.setResult( + tl.TaskResult.Failed, + `${failedReports.length} HTML report(s) contain failed tests` + ) + } } -try { - run() -} catch (error) { +run().catch((error) => { tl.error((error && error.message) || String(error)) if (error && error.stack) { tl.debug(error.stack) } tl.setResult(tl.TaskResult.Failed, (error && error.message) || String(error)) -} +}) diff --git a/tasks/UploadPortalHtmlReport/lib.js b/tasks/UploadPortalHtmlReport/lib.js index 7a6bce6..8062327 100644 --- a/tasks/UploadPortalHtmlReport/lib.js +++ b/tasks/UploadPortalHtmlReport/lib.js @@ -1,7 +1,7 @@ "use strict" -const { resolve, relative, basename, dirname, extname } = require("path") -const { statSync } = require("fs") +const { resolve, relative, basename, dirname, extname, isAbsolute, join } = require("path") +const { statSync, existsSync, readFileSync, writeFileSync } = require("fs") const globby = require("globby") const dashify = require("dashify") @@ -20,6 +20,28 @@ const FORBIDDEN_KEYS = [ const HTML_EXT = new Set([".html", ".htm"]) const ATTACHMENT_DELIMITER = "~" const FAILED_TESTS_RE = /Failed Tests\s+([0-9]+)/i +const PLAYWRIGHT_UNEXPECTED_RE = /"unexpected"\s*:\s*([0-9]+)/ +const ARCHIVE_IGNORE = ["**/node_modules/**", "**/.git/**", "**/.DS_Store"] +const MAX_INLINE_ASSET_BYTES = 2 * 1024 * 1024 +const DEFAULT_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 + +const MIME_TYPES = { + ".css": "text/css", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject" +} function toPosix(filePath) { return filePath.replace(/\\/g, "/") @@ -29,6 +51,17 @@ function isHtmlFile(filePath) { return HTML_EXT.has(extname(filePath).toLowerCase()) } +function sortHtmlFiles(files) { + return files.slice().sort((left, right) => { + const leftIndex = basename(left).toLowerCase() === "index.html" ? 0 : 1 + const rightIndex = basename(right).toLowerCase() === "index.html" ? 0 : 1 + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex + } + return toPosix(left).localeCompare(toPosix(right)) + }) +} + function findHtmlFiles(inputPath) { const resolved = resolve(inputPath) const stats = statSync(resolved) @@ -44,14 +77,14 @@ function findHtmlFiles(inputPath) { throw new Error(`Path is not a file or directory: ${resolved}`) } - return globby - .sync(["**/*.{html,htm,HTML,HTM}"], { + return sortHtmlFiles( + globby.sync(["**/*.{html,htm,HTML,HTM}"], { cwd: resolved, absolute: true, onlyFiles: true, followSymbolicLinks: false }) - .sort() + ) } function displayNameFor(filePath, rootPath) { @@ -108,9 +141,13 @@ function isReportSuccessful(html) { if (typeof html !== "string") { return true } - const match = html.match(FAILED_TESTS_RE) - if (match) { - return Number(match[1]) === 0 + const newman = html.match(FAILED_TESTS_RE) + if (newman) { + return Number(newman[1]) === 0 + } + const playwright = html.match(PLAYWRIGHT_UNEXPECTED_RE) + if (playwright) { + return Number(playwright[1]) === 0 } return true } @@ -170,16 +207,207 @@ function redactHtmlDocument(document) { return document } +function isRemoteOrSpecial(href) { + const value = String(href || "").trim() + if (!value) { + return true + } + if (/^(data:|https?:|\/\/|blob:|mailto:|javascript:)/i.test(value)) { + return true + } + if (value.charAt(0) === "#") { + return true + } + return false +} + +function resolveLocalPath(fromFile, href, rootDir) { + if (isRemoteOrSpecial(href)) { + return null + } + const cleaned = String(href).trim().split("#")[0].split("?")[0] + if (!cleaned) { + return null + } + const resolved = resolve(dirname(fromFile), cleaned) + const root = resolve(rootDir) + const rel = relative(root, resolved) + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + return null + } + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + return null + } + return resolved +} + +function mimeFor(filePath) { + return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream" +} + +function toDataUri(filePath) { + const buffer = readFileSync(filePath) + return `data:${mimeFor(filePath)};base64,${buffer.toString("base64")}` +} + +function inlineCssUrls(cssText, cssFilePath, rootDir, warnings) { + return cssText.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (match, quote, rawUrl) => { + const local = resolveLocalPath(cssFilePath, rawUrl.trim(), rootDir) + if (!local) { + return match + } + if (statSync(local).size > MAX_INLINE_ASSET_BYTES) { + warnings.push(`Skipped large CSS asset ${displayNameFor(local, rootDir)}`) + return match + } + return `url(${quote}${toDataUri(local)}${quote})` + }) +} + +function inlineLocalAssets(html, htmlPath, rootDir) { + const warnings = [] + const inlined = [] + const { load } = require("cheerio") + const document = load(html) + const root = rootDir || dirname(htmlPath) + + function takeLocal(href) { + const local = resolveLocalPath(htmlPath, href, root) + if (!local) { + return null + } + if (statSync(local).size > MAX_INLINE_ASSET_BYTES) { + warnings.push(`Skipped large asset ${displayNameFor(local, root)}`) + return null + } + return local + } + + document('link[rel="stylesheet"][href]').each(function () { + const href = document(this).attr("href") + const local = takeLocal(href) + if (!local) { + return + } + const css = inlineCssUrls(readFileSync(local, "utf8"), local, root, warnings) + const media = document(this).attr("media") + const mediaAttr = media ? ` media="${media}"` : "" + document(this).replaceWith(`\n${css}\n`) + inlined.push(displayNameFor(local, root)) + }) + + document("script[src]").each(function () { + const src = document(this).attr("src") + const local = takeLocal(src) + if (!local) { + return + } + const type = (document(this).attr("type") || "").toLowerCase() + const source = readFileSync(local, "utf8") + if (type === "module" && /\bimport\s/.test(source)) { + warnings.push(`Cannot fully inline ES module ${displayNameFor(local, root)}`) + return + } + const safe = source.replace(/<\/script/gi, "<\\/script") + const typeAttr = type ? ` type="${document(this).attr("type")}"` : "" + document(this).replaceWith(`\n${safe}\n`) + inlined.push(displayNameFor(local, root)) + }) + + document("img[src], source[src], video[src], audio[src], image[href], image[xlink\\:href]").each(function () { + const attr = document(this).attr("src") ? "src" : document(this).attr("href") ? "href" : "xlink:href" + const value = document(this).attr(attr) + const local = takeLocal(value) + if (!local) { + return + } + document(this).attr(attr, toDataUri(local)) + inlined.push(displayNameFor(local, root)) + }) + + document('link[rel="icon"][href], link[rel="shortcut icon"][href]').each(function () { + const href = document(this).attr("href") + const local = takeLocal(href) + if (!local) { + return + } + document(this).attr("href", toDataUri(local)) + inlined.push(displayNameFor(local, root)) + }) + + return { + html: document.html(), + inlined, + warnings + } +} + +function listArchiveFiles(rootDir) { + return globby.sync(["**/*"], { + cwd: rootDir, + absolute: false, + onlyFiles: true, + followSymbolicLinks: false, + ignore: ARCHIVE_IGNORE + }).sort() +} + +async function createReportArchive(rootDir, outPath, maxBytes) { + const JSZip = require("jszip") + const limit = maxBytes || DEFAULT_MAX_ARCHIVE_BYTES + const files = listArchiveFiles(rootDir) + if (files.length === 0) { + return { skipped: true, reason: "no files" } + } + + let total = 0 + const zip = new JSZip() + for (let i = 0; i < files.length; i++) { + const rel = files[i] + const abs = join(rootDir, rel) + total += statSync(abs).size + if (total > limit) { + return { skipped: true, reason: "archive would exceed size limit", bytes: total } + } + zip.file(toPosix(rel), readFileSync(abs)) + } + + const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }) + writeFileSync(outPath, buffer) + return { skipped: false, bytes: buffer.length, files: files.length } +} + +function parseSummaryPayload(payload) { + if (Array.isArray(payload)) { + return { reports: payload, archive: null } + } + if (payload && typeof payload === "object") { + return { + reports: payload.reports || [], + archive: payload.archive || null + } + } + return { reports: [], archive: null } +} + module.exports = { ATTACHMENT_DELIMITER, + DEFAULT_MAX_ARCHIVE_BYTES, FORBIDDEN_KEYS, + MAX_INLINE_ASSET_BYTES, + createReportArchive, displayNameFor, findHtmlFiles, generateAttachmentName, + inlineLocalAssets, isHtmlFile, isReportSuccessful, + listArchiveFiles, parseAttachmentName, + parseSummaryPayload, redactHtmlDocument, redactObject, - shouldRedactKey + resolveLocalPath, + shouldRedactKey, + sortHtmlFiles } diff --git a/tasks/UploadPortalHtmlReport/package-lock.json b/tasks/UploadPortalHtmlReport/package-lock.json index 33f2d0c..aa32b25 100644 --- a/tasks/UploadPortalHtmlReport/package-lock.json +++ b/tasks/UploadPortalHtmlReport/package-lock.json @@ -1,18 +1,19 @@ { "name": "uploadportalhtmlreport", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "uploadportalhtmlreport", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "azure-pipelines-task-lib": "^4.17.3", "cheerio": "^1.0.0-rc.12", "dashify": "^2.0.0", - "globby": "^11.1.0" + "globby": "^11.1.0", + "jszip": "3.10.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -177,6 +178,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -546,6 +553,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -617,6 +630,33 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -711,6 +751,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -796,6 +842,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -827,6 +879,21 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -892,6 +959,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -916,6 +989,12 @@ "semver": "bin/semver" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shelljs": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", @@ -942,6 +1021,15 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -990,6 +1078,12 @@ "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "license": "(WTFPL OR MIT)" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", diff --git a/tasks/UploadPortalHtmlReport/package.json b/tasks/UploadPortalHtmlReport/package.json index 6bf05bd..1f86ea5 100644 --- a/tasks/UploadPortalHtmlReport/package.json +++ b/tasks/UploadPortalHtmlReport/package.json @@ -1,6 +1,6 @@ { "name": "uploadportalhtmlreport", - "version": "1.2.0", + "version": "1.3.0", "description": "Publish HTML reports as Azure Pipelines attachments", "main": "index.js", "scripts": { @@ -13,6 +13,7 @@ "azure-pipelines-task-lib": "^4.17.3", "cheerio": "^1.0.0-rc.12", "dashify": "^2.0.0", - "globby": "^11.1.0" + "globby": "^11.1.0", + "jszip": "3.10.1" } } diff --git a/tasks/UploadPortalHtmlReport/task.json b/tasks/UploadPortalHtmlReport/task.json index efb8e9f..5348b39 100644 --- a/tasks/UploadPortalHtmlReport/task.json +++ b/tasks/UploadPortalHtmlReport/task.json @@ -2,7 +2,7 @@ "id": "4d9a74ab-346a-4549-936a-6a3d3ad77227", "name": "UploadPortalHtmlReport", "friendlyName": "Upload HTML Report", - "description": "Publish self-contained HTML reports as a tab on the pipeline run", + "description": "Publish HTML reports as a tab on the pipeline run, inlining local CSS/JS/images", "author": "Jeff Jones", "helpUrl": "https://github.com/joneja09/azure-pipelines-html-viewer#configuration", "helpMarkDown": "[More Information](https://github.com/joneja09/azure-pipelines-html-viewer#configuration)", @@ -14,7 +14,7 @@ "demands": [], "version": { "Major": "1", - "Minor": "2", + "Minor": "3", "Patch": "0" }, "minimumAgentVersion": "2.144.0", @@ -36,13 +36,29 @@ "required": false, "helpMarkDown": "Name of the tab displayed on the pipeline run. Run the task multiple times with different tab names to publish several report groups." }, + { + "name": "inlineAssets", + "type": "boolean", + "label": "Inline local CSS, JS, and images", + "defaultValue": true, + "required": false, + "helpMarkDown": "Embeds local stylesheets, scripts, and images into each HTML file so coverage and similar multi-file reports render in the pipeline tab. Disable if the HTML is already self-contained." + }, + { + "name": "publishArchive", + "type": "boolean", + "label": "Publish zip of the report folder", + "defaultValue": true, + "required": false, + "helpMarkDown": "When reportDir is a directory, attaches a zip of the folder so the tab can offer Download all. Skipped if the folder would exceed 50 MB." + }, { "name": "redactSecrets", "type": "boolean", "label": "Redact secrets in HTML", "defaultValue": false, "required": false, - "helpMarkDown": "When enabled, Bearer tokens and common secret keys (password, access_token, etc.) are masked before upload. Intended for Postman/Newman HTML Extra reports. Leave off for generic HTML to avoid rewriting the file." + "helpMarkDown": "When enabled, Bearer tokens and common secret keys (password, access_token, etc.) are masked before upload. Intended for Postman/Newman HTML Extra reports." }, { "name": "failOnEmpty", @@ -51,6 +67,14 @@ "defaultValue": true, "required": false, "helpMarkDown": "Fails the task when the path does not contain any .html/.htm files. Disable if missing reports should only warn." + }, + { + "name": "failOnFailedReports", + "type": "boolean", + "label": "Fail if a report contains failed tests", + "defaultValue": false, + "required": false, + "helpMarkDown": "Fails the task after publishing when a report looks unsuccessful (Newman Failed Tests, Playwright unexpected count). Reports are still uploaded." } ], "execution": { diff --git a/tasks/UploadPortalHtmlReport/tests/lib.test.js b/tasks/UploadPortalHtmlReport/tests/lib.test.js index 822673b..81ca980 100644 --- a/tasks/UploadPortalHtmlReport/tests/lib.test.js +++ b/tasks/UploadPortalHtmlReport/tests/lib.test.js @@ -1,6 +1,8 @@ const { test, describe } = require("node:test") const assert = require("node:assert/strict") const { join } = require("path") +const { mkdtempSync, writeFileSync, mkdirSync, readFileSync } = require("fs") +const { tmpdir } = require("os") const { load } = require("cheerio") const { findHtmlFiles, @@ -10,7 +12,11 @@ const { isReportSuccessful, redactObject, redactHtmlDocument, - shouldRedactKey + shouldRedactKey, + inlineLocalAssets, + createReportArchive, + parseSummaryPayload, + sortHtmlFiles } = require("../lib") const fixtures = join(__dirname, "fixtures") @@ -91,6 +97,11 @@ describe("isReportSuccessful", () => { test("does not throw when Failed Tests is missing", () => { assert.equal(isReportSuccessful(""), true) }) + + test("detects Playwright unexpected failures", () => { + assert.equal(isReportSuccessful('{"stats":{"expected":3,"unexpected":2}}'), false) + assert.equal(isReportSuccessful('{"stats":{"expected":3,"unexpected":0}}'), true) + }) }) describe("secret redaction", () => { @@ -126,3 +137,90 @@ describe("secret redaction", () => { assert.match(output, /"user": "ada"/) }) }) + +describe("sortHtmlFiles", () => { + test("puts index.html first", () => { + const sorted = sortHtmlFiles([ + "/tmp/reports/other.html", + "/tmp/reports/index.html", + "/tmp/reports/about.html" + ]).map((file) => file.split("/").pop()) + assert.deepEqual(sorted, ["index.html", "about.html", "other.html"]) + }) +}) + +describe("inlineLocalAssets", () => { + test("inlines local css, js, images, and css url() references", () => { + const dir = mkdtempSync(join(tmpdir(), "html-inline-")) + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" + ) + writeFileSync(join(dir, "logo.png"), png) + writeFileSync(join(dir, "style.css"), "body { background: url('./logo.png'); color: red; }") + writeFileSync(join(dir, "app.js"), "window.READY = true;") + writeFileSync( + join(dir, "index.html"), + "" + ) + + const result = inlineLocalAssets( + readFileSync(join(dir, "index.html"), "utf8"), + join(dir, "index.html"), + dir + ) + assert.equal(result.warnings.length, 0) + assert.ok(result.inlined.indexOf("style.css") >= 0) + assert.ok(result.inlined.indexOf("app.js") >= 0) + assert.doesNotMatch(result.html, /href="style\.css"/) + assert.doesNotMatch(result.html, /src="app\.js"/) + assert.doesNotMatch(result.html, /src="logo\.png"/) + assert.match(result.html, /