diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1ffaa6..3270e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [master] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -24,3 +27,21 @@ jobs: - name: Test upload task run: npm test working-directory: tasks/UploadPortalHtmlReport + + - name: Test GitHub Action helpers + run: node --test github-action/*.test.js + + publish-fixtures: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Publish fixture HTML reports + uses: ./ + with: + report-dir: tasks/UploadPortalHtmlReport/tests/fixtures + name: Fixture Reports + fail-on-failed-reports: false + comment-on-pr: true 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..4f237ac 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,77 @@ -# Azure DevOps HTML Report Portal +# CI HTML Viewer -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 from CI and view them where the work happens: -Each tab embeds the report in the pipeline UI and provides a download link. +- **GitHub Actions** — workflow summary, artifact download, and a sticky pull request comment +- **Azure Pipelines** — an embedded tab on the build or release result page This project is a fork of [maciejmaciejewski/azure-pipelines-postman](https://github.com/maciejmaciejewski/azure-pipelines-postman), generalized beyond Postman reports. -## Configuration +## GitHub Actions -Add the **Upload HTML Report** task after your tests produce HTML output. Use `condition: succeededOrFailed()` so reports still publish when tests fail. +The repository is an action. On a pull request it posts (or updates) a comment with pass/fail for each HTML file and a link to the workflow artifacts. On `push` it still writes the job summary and uploads artifacts. -The task takes: +```yaml +name: Tests +on: + pull_request: + push: + branches: [main] + +permissions: + contents: write # push HTML preview to gh-pages + pull-requests: write # sticky PR comment + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm test -- --reporter=html --output=reports + - uses: joneja09/azure-pipelines-html-viewer@v1 + if: always() + with: + report-dir: reports + name: Test Reports + fail-on-failed-reports: true +``` + +If this repository is renamed to `ci-html-viewer`, change that to `joneja09/ci-html-viewer@v1`. Until then, use the current repo name. + +| Input | Default | Description | +| --- | --- | --- | +| `report-dir` | required | A single `.html`/`.htm` file, or a directory searched recursively | +| `name` | `HTML Report` | Artifact name, summary heading, and PR comment title | +| `inline-assets` | `true` | Embed local CSS, JS, and images | +| `publish-archive` | `true` | Include a zip of a report directory in the artifact | +| `redact-secrets` | `false` | Mask Bearer tokens and common secret keys | +| `fail-on-empty` | `true` | Fail when no HTML files are found | +| `fail-on-failed-reports` | `false` | Fail the job after publishing when a report looks unsuccessful | +| `comment-on-pr` | `true` | Post or update a sticky comment on `pull_request` workflows | +| `pages-preview` | `true` | Publish inlined HTML to the `gh-pages` branch and link each report in the PR comment | +| `upload-artifact` | `true` | Upload prepared reports as a workflow artifact | + +GitHub cannot embed a full HTML report inside a PR thread (comments are markdown). The comment is the scoreboard; each report name links to a GitHub Pages preview of that HTML file. Re-runs update the same sticky comment (``) instead of adding a new one. + +Enable **Settings → Pages → Deploy from branch `gh-pages`** once so those links render. Until that is set, the comment still posts and artifacts still upload; HTML links may 404. Preview deploy is best-effort and will not fail the job if the push is denied. + +Fork PRs only get a comment when the workflow token has `pull-requests: write`. Job summaries and artifacts still publish. -- `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`) +## Azure Pipelines -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. +Add the **Upload HTML Report** task after your tests produce HTML. Use `condition: succeededOrFailed()` so reports still publish when tests fail. + +| 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 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 | + +`index.html` is listed first when a directory contains several HTML files. A single report is expanded automatically in the tab. ```yaml steps: @@ -39,28 +93,35 @@ 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 download the zip / GitHub artifact of 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. +Run the Azure task more than once with different `tabName` values to publish multiple report groups. For GitHub Actions, run the action multiple times with different `name` values (each gets its own artifact and sticky comment). ![](./docs/postman-report-2.png) ## Example -### Report summary on the build tab +### Report summary on the Azure DevOps build tab ![](./docs/postman-report-1.png) +## Repository name + +`ci-html-viewer` is a better GitHub name now that this is not Azure-only. Rename in GitHub Settings when you are ready; clone URLs will change, but `action.yml` at the repo root already matches that identity. Keep the Azure DevOps extension id (`html-report-portal`) as-is so existing installs do not break. + ## Development ```bash @@ -68,9 +129,10 @@ npm install npm run build npm install --prefix tasks/UploadPortalHtmlReport npm test +node --test github-action/*.test.js ``` -The upload task runs on Node 16 and Node 20 pipeline agents. Node 10 is no longer supported. +The Azure upload task runs on Node 16 and Node 20 pipeline agents. Node 10 is no longer supported. ## Contributors diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..5cd98c3 --- /dev/null +++ b/action.yml @@ -0,0 +1,164 @@ +name: CI HTML Viewer +description: Publish HTML reports to the workflow summary, artifacts, and the pull request +author: Jeff Jones + +branding: + icon: file-code + color: blue + +inputs: + report-dir: + description: Path to a single HTML file or a directory of HTML reports + required: true + name: + description: Name used for the artifact, job summary heading, and PR comment + required: false + default: HTML Report + inline-assets: + description: Embed local CSS, JS, and images into each HTML file + required: false + default: "true" + publish-archive: + description: Include a zip of the original report directory in the artifact + required: false + default: "true" + redact-secrets: + description: Mask Bearer tokens and common secret keys in HTML + required: false + default: "false" + fail-on-empty: + description: Fail when no HTML files are found + required: false + default: "true" + fail-on-failed-reports: + description: Fail the job after publishing when a report looks unsuccessful + required: false + default: "false" + comment-on-pr: + description: Post or update a sticky comment when the workflow runs on a pull request + required: false + default: "true" + pages-preview: + description: Publish inlined HTML to GitHub Pages (gh-pages branch) and link each report in the PR comment + required: false + default: "true" + upload-artifact: + description: Upload the prepared reports as a workflow artifact + required: false + default: "true" + github-token: + description: Token used to comment on pull requests + required: false + default: ${{ github.token }} + +outputs: + output-dir: + description: Directory containing prepared HTML reports + value: ${{ steps.prepare.outputs.output-dir }} + artifact-name: + description: Sanitized artifact name + value: ${{ steps.prepare.outputs.artifact-name }} + report-count: + description: Number of HTML reports published + value: ${{ steps.prepare.outputs.report-count }} + failed-count: + description: Number of reports detected as unsuccessful + value: ${{ steps.prepare.outputs.failed-count }} + +runs: + using: composite + steps: + - name: Install report tooling + shell: bash + run: npm ci --omit=dev --prefix "$GITHUB_ACTION_PATH/tasks/UploadPortalHtmlReport" + + - name: Prepare HTML reports + id: prepare + shell: bash + env: + INPUT_REPORT_DIR: ${{ inputs.report-dir }} + INPUT_NAME: ${{ inputs.name }} + INPUT_INLINE_ASSETS: ${{ inputs.inline-assets }} + INPUT_PUBLISH_ARCHIVE: ${{ inputs.publish-archive }} + INPUT_REDACT_SECRETS: ${{ inputs.redact-secrets }} + INPUT_FAIL_ON_EMPTY: ${{ inputs.fail-on-empty }} + INPUT_FAIL_ON_FAILED_REPORTS: ${{ inputs.fail-on-failed-reports }} + run: node "$GITHUB_ACTION_PATH/github-action/run.js" + + - name: Upload report artifact + if: ${{ always() && inputs.upload-artifact == 'true' && steps.prepare.outputs.output-dir != '' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.prepare.outputs.artifact-name }} + path: ${{ steps.prepare.outputs.output-dir }} + if-no-files-found: warn + + - name: Publish HTML preview to GitHub Pages + id: preview + if: ${{ always() && inputs.pages-preview == 'true' && github.event_name == 'pull_request' && steps.prepare.outputs.output-dir != '' }} + continue-on-error: true + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ inputs.github-token }} + publish_dir: ${{ steps.prepare.outputs.output-dir }}/reports + destination_dir: pr/${{ github.event.pull_request.number }}/${{ steps.prepare.outputs.artifact-name }} + keep_files: true + enable_jekyll: false + commit_message: Preview HTML reports for PR ${{ github.event.pull_request.number }} + + - name: Render PR comment + id: render + if: ${{ always() && steps.prepare.outputs.output-dir != '' }} + shell: bash + env: + OUTPUT_DIR: ${{ steps.prepare.outputs.output-dir }} + REPORT_NAME: ${{ inputs.name }} + PREVIEW_OUTCOME: ${{ steps.preview.outcome }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node "$GITHUB_ACTION_PATH/github-action/render-comment.js" + + - name: Comment on pull request + if: ${{ always() && inputs.comment-on-pr == 'true' && github.event_name == 'pull_request' && steps.render.outputs.comment-markdown != '' }} + uses: actions/github-script@v7 + env: + COMMENT_MARKDOWN: ${{ steps.render.outputs.comment-markdown }} + REPORT_NAME: ${{ inputs.name }} + with: + github-token: ${{ inputs.github-token }} + script: | + const marker = `` + const body = process.env.COMMENT_MARKDOWN + const pr = context.payload.pull_request + if (!body || !pr) { + return + } + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }) + const existing = comments.find((comment) => comment.body && comment.body.indexOf(marker) !== -1) + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }) + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body + }) + } + + - name: Fail on failed reports + if: ${{ always() && inputs.fail-on-failed-reports == 'true' && steps.prepare.outputs.failed == 'true' }} + shell: bash + run: | + echo "::error::${{ steps.prepare.outputs.failed-count }} HTML report(s) contain failed tests" + exit 1 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/github-action/prepare.js b/github-action/prepare.js new file mode 100644 index 0000000..21b0428 --- /dev/null +++ b/github-action/prepare.js @@ -0,0 +1,231 @@ +"use strict" + +const { resolve, join, dirname } = require("path") +const { readFileSync, writeFileSync, mkdirSync, statSync } = require("fs") +const { tmpdir } = require("os") +const { + findHtmlFiles, + displayNameFor, + isReportSuccessful, + redactHtmlDocument, + inlineLocalAssets, + createReportArchive +} = require("../tasks/UploadPortalHtmlReport/lib") + +const MAX_REDACT_BYTES = 5 * 1024 * 1024 + +function isTruthy(value, defaultValue) { + if (value === undefined || value === null || value === "") { + return defaultValue + } + const normalized = String(value).toLowerCase() + if (normalized === "true" || normalized === "1" || normalized === "yes") { + return true + } + if (normalized === "false" || normalized === "0" || normalized === "no") { + return false + } + return defaultValue +} + +function commentMarker(name) { + return `` +} + +function sanitizeArtifactName(name) { + const cleaned = String(name || "html-report") + .replace(/["<>:|?*\\/\r\n]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .trim() + return cleaned || "html-report" +} + +function githubPagesPreviewUrl({ owner, repo, prNumber, artifactName }) { + if (!owner || !repo || !prNumber || !artifactName) { + return "" + } + return `https://${owner}.github.io/${repo}/pr/${prNumber}/${sanitizeArtifactName(artifactName)}` +} + +function htmlFileUrl(previewBaseUrl, fileName) { + if (!previewBaseUrl) { + return "" + } + const base = String(previewBaseUrl).replace(/\/$/, "") + const path = String(fileName || "").split("/").map(encodeURIComponent).join("/") + return base + "/" + path +} + +function reportCell(fileName, previewBaseUrl) { + const url = htmlFileUrl(previewBaseUrl, fileName) + if (!url) { + return fileName + } + return `[${fileName}](${url})` +} + +function statusLabel(successful) { + return successful ? "passed" : "failed" +} + +function statusIcon(successful) { + return successful ? "✅" : "❌" +} + +function buildCommentMarkdown({ name, reports, failedCount, runUrl, artifactName, archiveIncluded, previewBaseUrl }) { + const marker = commentMarker(name) + const passedCount = reports.length - failedCount + const headline = failedCount + ? `**${failedCount} failed**, ${passedCount} passed` + : `**${passedCount} passed**` + + const rows = reports + .map((report) => `| ${reportCell(report.fileName, previewBaseUrl)} | ${statusIcon(report.successful)} ${statusLabel(report.successful)} |`) + .join("\n") + + const links = [] + if (previewBaseUrl) { + links.push(`[Open HTML report](${htmlFileUrl(previewBaseUrl, "index.html")})`) + } + if (runUrl) { + links.push(`[Workflow artifacts](${runUrl})`) + } + const extra = archiveIncluded + ? `A zip of the original report folder is included in the **${artifactName}** artifact.` + : `Inlined HTML is uploaded as the **${artifactName}** artifact.` + const previewNote = previewBaseUrl + ? " If an HTML link 404s, set GitHub Pages source to the `gh-pages` branch." + : "" + const linkBlock = links.length ? `\n${links.join(" · ")}\n` : "\n" + + return `${marker} +## ${name} + +${headline} (${reports.length} report${reports.length === 1 ? "" : "s"}) + +| Report | Result | +| --- | --- | +${rows} +${linkBlock}${extra}${previewNote} +` +} + +async function prepareReports(options) { + const reportDir = resolve(options.reportDir) + const name = options.name || "HTML Report" + const inlineAssets = options.inlineAssets !== false + const redactSecrets = !!options.redactSecrets + const failOnEmpty = options.failOnEmpty !== false + const publishArchive = options.publishArchive !== false + const outputRoot = options.outputDir || join(options.tempDir || tmpdir(), "ci-html-viewer", String(Date.now())) + const reportsDir = join(outputRoot, "reports") + mkdirSync(reportsDir, { recursive: true }) + + const reportStats = statSync(reportDir) + const files = findHtmlFiles(reportDir) + if (files.length === 0) { + return { + reports: [], + failedCount: 0, + empty: true, + failOnEmpty, + outputDir: outputRoot, + artifactName: sanitizeArtifactName(name), + archiveIncluded: false, + markdown: `${commentMarker(name)}\n## ${name}\n\nNo HTML files found in \`${reportDir}\`.\n` + } + } + + const reports = [] + const root = reportStats.isDirectory() ? reportDir : dirname(reportDir) + + files.forEach((file) => { + const relativeName = displayNameFor(file, reportDir) + let html = readFileSync(file, "utf8") + + if (inlineAssets) { + const inlined = inlineLocalAssets(html, file, root) + html = inlined.html + } + + if (redactSecrets && Buffer.byteLength(html, "utf8") <= MAX_REDACT_BYTES) { + const { load } = require("cheerio") + const document = load(html) + redactHtmlDocument(document) + html = document.html() + } + + const outFile = join(reportsDir, relativeName) + mkdirSync(dirname(outFile), { recursive: true }) + writeFileSync(outFile, html) + + reports.push({ + fileName: relativeName, + successful: isReportSuccessful(html) + }) + }) + + writeFileSync(join(reportsDir, ".nojekyll"), "") + const hasIndex = reports.some((report) => report.fileName.toLowerCase() === "index.html") + if (!hasIndex && reports.length) { + const items = reports + .map((report) => { + const href = report.fileName.split("/").map(encodeURIComponent).join("/") + const label = report.fileName.replace(/&/g, "&").replace(/${label} ${report.successful ? "passed" : "failed"}` + }) + .join("") + const title = String(name).replace(/&/g, "&").replace(/${title}

${title}

\n` + ) + } + + let archiveIncluded = false + if (publishArchive && reportStats.isDirectory()) { + const archivePath = join(outputRoot, "html-reports.zip") + const archiveResult = await createReportArchive(reportDir, archivePath) + archiveIncluded = !archiveResult.skipped + } + + const failedCount = reports.filter((item) => item.successful === false).length + const artifactName = sanitizeArtifactName(name) + const markdown = buildCommentMarkdown({ + name, + reports, + failedCount, + runUrl: options.runUrl, + artifactName, + archiveIncluded, + previewBaseUrl: options.previewBaseUrl || "" + }) + + writeFileSync( + join(outputRoot, "summary.json"), + JSON.stringify({ version: 2, name, reports, archiveIncluded }, null, 2) + ) + + return { + reports, + failedCount, + empty: false, + failOnEmpty, + outputDir: outputRoot, + artifactName, + archiveIncluded, + markdown + } +} + +module.exports = { + buildCommentMarkdown, + commentMarker, + githubPagesPreviewUrl, + htmlFileUrl, + isTruthy, + prepareReports, + sanitizeArtifactName +} diff --git a/github-action/prepare.test.js b/github-action/prepare.test.js new file mode 100644 index 0000000..fe6ed88 --- /dev/null +++ b/github-action/prepare.test.js @@ -0,0 +1,161 @@ +const { test, describe } = require("node:test") +const assert = require("node:assert/strict") +const { join } = require("path") +const { mkdtempSync, writeFileSync, readFileSync, mkdirSync } = require("fs") +const { tmpdir } = require("os") +const { + buildCommentMarkdown, + commentMarker, + githubPagesPreviewUrl, + htmlFileUrl, + isTruthy, + prepareReports, + sanitizeArtifactName +} = require("./prepare") + +describe("helpers", () => { + test("isTruthy treats missing values as the default", () => { + assert.equal(isTruthy(undefined, true), true) + assert.equal(isTruthy("", false), false) + assert.equal(isTruthy("true", false), true) + assert.equal(isTruthy("false", true), false) + }) + + test("sanitizeArtifactName strips illegal characters", () => { + assert.equal(sanitizeArtifactName("QA: Reports/v1"), "QA-Reports-v1") + }) + + test("githubPagesPreviewUrl builds a project Pages path", () => { + assert.equal( + githubPagesPreviewUrl({ + owner: "acme", + repo: "ci-html-viewer", + prNumber: 7, + artifactName: "Fixture Reports" + }), + "https://acme.github.io/ci-html-viewer/pr/7/Fixture-Reports" + ) + assert.equal(githubPagesPreviewUrl({ owner: "acme" }), "") + }) + + test("htmlFileUrl encodes each path segment", () => { + assert.equal( + htmlFileUrl("https://acme.github.io/repo/pr/1/reports", "nested/My Report.html"), + "https://acme.github.io/repo/pr/1/reports/nested/My%20Report.html" + ) + assert.equal(htmlFileUrl("", "index.html"), "") + }) +}) + +describe("buildCommentMarkdown", () => { + test("includes a sticky marker, table, and workflow link", () => { + const markdown = buildCommentMarkdown({ + name: "Coverage", + reports: [ + { fileName: "index.html", successful: true }, + { fileName: "nested/fail.html", successful: false } + ], + failedCount: 1, + runUrl: "https://github.com/acme/repo/actions/runs/9", + artifactName: "Coverage", + archiveIncluded: true + }) + assert.match(markdown, new RegExp(commentMarker("Coverage"))) + assert.match(markdown, /\*\*1 failed\*\*, 1 passed/) + assert.match(markdown, /index\.html/) + assert.match(markdown, /nested\/fail\.html/) + assert.match(markdown, /https:\/\/github.com\/acme\/repo\/actions\/runs\/9/) + assert.match(markdown, /zip of the original report folder/) + assert.doesNotMatch(markdown, /\[index\.html\]\(/) + }) + + test("links each HTML file when a preview base URL is provided", () => { + const markdown = buildCommentMarkdown({ + name: "Coverage", + reports: [ + { fileName: "index.html", successful: true }, + { fileName: "nested/fail.html", successful: false } + ], + failedCount: 1, + runUrl: "https://github.com/acme/repo/actions/runs/9", + artifactName: "Coverage", + archiveIncluded: false, + previewBaseUrl: "https://acme.github.io/repo/pr/7/Coverage" + }) + assert.match( + markdown, + /\[index\.html\]\(https:\/\/acme\.github\.io\/repo\/pr\/7\/Coverage\/index\.html\)/ + ) + assert.match( + markdown, + /\[nested\/fail\.html\]\(https:\/\/acme\.github\.io\/repo\/pr\/7\/Coverage\/nested\/fail\.html\)/ + ) + assert.match(markdown, /\[Open HTML report\]\(https:\/\/acme\.github\.io\/repo\/pr\/7\/Coverage\/index\.html\)/) + }) +}) + +describe("prepareReports", () => { + test("inlines assets, writes reports, and builds PR markdown", async () => { + const dir = mkdtempSync(join(tmpdir(), "gha-html-")) + writeFileSync(join(dir, "style.css"), "h1{color:navy}") + writeFileSync( + join(dir, "index.html"), + "

OK

" + ) + mkdirSync(join(dir, "extra"), { recursive: true }) + writeFileSync(join(dir, "extra", "other.html"), "Other") + + const result = await prepareReports({ + reportDir: dir, + name: "Coverage", + outputDir: join(dir, "out"), + runUrl: "https://example.test/run/1" + }) + + assert.equal(result.empty, false) + assert.equal(result.reports.length, 2) + assert.equal(result.reports[0].fileName, "index.html") + assert.equal(result.archiveIncluded, true) + assert.equal(result.failedCount, 0) + const inlined = readFileSync(join(result.outputDir, "reports", "index.html"), "utf8") + assert.match(inlined, /h1\{color:navy\}/) + assert.equal(require("fs").existsSync(join(result.outputDir, "reports", ".nojekyll")), true) + assert.match(result.markdown, /Coverage/) + assert.match(result.markdown, /https:\/\/example.test\/run\/1/) + }) + + test("writes a listing index when the reports have none", async () => { + const dir = mkdtempSync(join(tmpdir(), "gha-listing-")) + writeFileSync(join(dir, "newman-pass.html"), "OK") + mkdirSync(join(dir, "nested"), { recursive: true }) + writeFileSync(join(dir, "nested", "deep.html"), "Deep") + + const result = await prepareReports({ + reportDir: dir, + name: "Fixture Reports", + outputDir: join(dir, "out"), + publishArchive: false + }) + + const listing = readFileSync(join(result.outputDir, "reports", "index.html"), "utf8") + assert.match(listing, /newman-pass\.html/) + assert.match(listing, /nested\/deep\.html/) + assert.equal( + require("fs").existsSync(join(result.outputDir, "reports", ".nojekyll")), + true + ) + }) + + test("returns empty when no HTML is present", async () => { + const dir = mkdtempSync(join(tmpdir(), "gha-empty-")) + writeFileSync(join(dir, "notes.txt"), "nope") + const result = await prepareReports({ + reportDir: dir, + name: "HTML Report", + outputDir: join(dir, "out") + }) + assert.equal(result.empty, true) + assert.equal(result.reports.length, 0) + assert.match(result.markdown, /No HTML files found/) + }) +}) diff --git a/github-action/render-comment.js b/github-action/render-comment.js new file mode 100644 index 0000000..f34662d --- /dev/null +++ b/github-action/render-comment.js @@ -0,0 +1,70 @@ +"use strict" + +const { readFileSync, appendFileSync } = require("fs") +const { join } = require("path") +const { + buildCommentMarkdown, + githubPagesPreviewUrl, + sanitizeArtifactName +} = require("./prepare") + +function setOutput(name, value) { + const dest = process.env.GITHUB_OUTPUT + if (!dest) { + console.log(`${name}=${value}`) + return + } + const text = String(value) + if (text.indexOf("\n") >= 0) { + appendFileSync(dest, `${name}< report.successful === false).length +const artifactName = sanitizeArtifactName(name) +const markdown = buildCommentMarkdown({ + name, + reports, + failedCount, + runUrl: runUrlFromEnv(), + artifactName, + archiveIncluded: !!summary.archiveIncluded, + previewBaseUrl: previewBaseUrlFromEnv(artifactName) +}) + +setOutput("comment-markdown", markdown) diff --git a/github-action/run.js b/github-action/run.js new file mode 100644 index 0000000..19bb82c --- /dev/null +++ b/github-action/run.js @@ -0,0 +1,85 @@ +"use strict" + +const { appendFileSync } = require("fs") +const { tmpdir } = require("os") +const { prepareReports, isTruthy } = require("./prepare") + +function readInput(name) { + const underscored = "INPUT_" + name.replace(/-/g, "_").toUpperCase() + const dashed = "INPUT_" + name.toUpperCase() + if (process.env[underscored] !== undefined) { + return process.env[underscored] + } + return process.env[dashed] +} + +function setOutput(name, value) { + const dest = process.env.GITHUB_OUTPUT + if (!dest) { + console.log(`${name}=${value}`) + return + } + const text = String(value) + if (text.indexOf("\n") >= 0) { + appendFileSync(dest, `${name}<\n/, "")) + setOutput("output-dir", result.outputDir) + setOutput("artifact-name", result.artifactName) + setOutput("report-count", String(result.reports.length)) + setOutput("failed-count", String(result.failedCount)) + setOutput("empty", result.empty ? "true" : "false") + setOutput("failed", result.failedCount > 0 ? "true" : "false") + setOutput("comment-markdown", result.markdown) + + if (result.empty && result.failOnEmpty) { + throw new Error(`No HTML files found in ${reportDir}`) + } + + console.log(`Prepared ${result.reports.length} HTML report(s) in ${result.outputDir}`) +} + +main().catch((error) => { + console.error(error && error.message ? error.message : error) + process.exit(1) +}) diff --git a/overview.md b/overview.md index 39d2f34..7f80231 100644 --- a/overview.md +++ b/overview.md @@ -1,8 +1,8 @@ -# HTML Report Portal +# CI HTML Viewer -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 from Azure Pipelines (build/release tab) or GitHub Actions (job summary, artifacts, and a sticky pull request comment). -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. 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..c8de0d0 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "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": { "clean": "rimraf ./dist", "build": "npm run clean && webpack --mode development", - "test": "npm test --prefix tasks/UploadPortalHtmlReport", + "test": "npm test --prefix tasks/UploadPortalHtmlReport && node --test github-action/*.test.js", "package": "tfx extension create --manifest-globs azure-devops-extension.json" }, "repository": { 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, /