From fddbb161e434ea38a617067d503765f6b7fe56d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:19:39 +0000 Subject: [PATCH 1/2] feat(report): restyle the HTML build report to match Angles UI The downloadable build report (GET /build/:buildId/report) now uses the Angles UI "Ember & Sand" design tokens and layout instead of Bootstrap 4, and stays a single self-contained file that works offline. - Inline styles and script; no CDN stylesheet or external requests. - Header card with build status, team / component / environment / phase, start, finish, duration, result counts and a results bar. - Suites, tests and actions expand and collapse with native
, so the report still works with scripts disabled. Failed actions open by default. - Toolbar: filter by result, filter by test name / suite / feature / tag, "Show failures", "Expand all" and "Collapse all". - Steps show expected vs actual, info text, and a screenshot thumbnail that opens in a viewer. - Light and dark themes follow the OS preference; responsive down to phone width; everything expands when printed. - Fix date formats that used MM (month) where minutes were meant. - Resolve the component name and screenshot thumbnails at render time instead of embedding raw JSON in a script tag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf --- app/assets/report/index.pug | 442 ++++++++++------ app/assets/report/report.js | 112 ++++ app/assets/report/styles.css | 981 +++++++++++++++++++++++++++++++---- test/build.tests.js | 5 +- 4 files changed, 1280 insertions(+), 260 deletions(-) create mode 100644 app/assets/report/report.js diff --git a/app/assets/report/index.pug b/app/assets/report/index.pug index c8ccb61..08bacec 100644 --- a/app/assets/report/index.pug +++ b/app/assets/report/index.pug @@ -1,167 +1,281 @@ -html - head - title= "Angles Report" - link( - rel='stylesheet', - type='text/css', - href='https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css', - integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO", - crossorigin="anonymous" +//- Angles build report. +//- Rendered by buildController.getReport and saved by users as a standalone HTML file, so +//- the styles and script are inlined and nothing is loaded from the network. Expand and +//- collapse use native
/, which keeps working with scripts disabled. +- + const STATUSES = ['PASS', 'FAIL', 'ERROR', 'SKIPPED']; + const STATUS_LABELS = { PASS: 'Passed', FAIL: 'Failed', ERROR: 'Error', SKIPPED: 'Skipped' }; + const toCounts = (result) => { + const raw = result instanceof Map ? Object.fromEntries(result) : (result || {}); + const counts = { total: 0 }; + STATUSES.forEach((status) => { + counts[status] = Number(raw[status]) || 0; + counts.total += counts[status]; + }); + return counts; + }; + const formatDate = (date) => (date ? moment(date).format('DD MMM YYYY, HH:mm:ss') : '—'); + const formatTime = (date) => (date ? moment(date).format('HH:mm:ss') : ''); + const formatDuration = (start, end) => { + if (!start || !end) return '—'; + const ms = moment(end).diff(moment(start)); + if (ms < 0) return '—'; + if (ms < 1000) return `${ms}ms`; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes) return `${minutes}m ${seconds}s`; + return `${seconds}s`; + }; + const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`; + const lower = (status) => String(status || '').toLowerCase(); + const describePlatform = (platform) => [ + [platform.platformName, platform.platformVersion].filter(Boolean).join(' '), + [platform.browserName, platform.browserVersion].filter(Boolean).join(' '), + platform.deviceName, + ].filter(Boolean).join(' · '); + + const buildCounts = toCounts(build.result); + const components = (build.team && build.team.components) || []; + const component = components.find((c) => String(c._id) === String(build.component)); + const screenshotsById = {}; + (screenshots || []).forEach((screenshot) => { + let src = screenshot.thumbnail || ''; + if (src && !src.startsWith('data:image')) src = `data:image/png;base64,${src}`; + screenshotsById[String(screenshot._id)] = { src, view: screenshot.view }; + }); + const allExecutions = []; + (build.suites || []).forEach((suite) => (suite.executions || []).forEach((e) => allExecutions.push(e))); + const executionCounts = { ALL: allExecutions.length }; + STATUSES.forEach((status) => { + executionCounts[status] = allExecutions.filter((e) => e.status === status).length; + }); + +mixin resultsBar(counts) + .results-bar(role="img" aria-label=STATUSES.map((s) => `${STATUS_LABELS[s]}: ${counts[s]}`).join(', ')) + each status in STATUSES + if counts[status] > 0 + .results-bar-segment( + class=`status-bg-${lower(status)}` + style=`flex-grow: ${counts[status]}` + title=`${STATUS_LABELS[status]}: ${counts[status]}` ) - style - include styles.css - - script(type = "text/javascript"). - window.onload = function () { - function setComponentName() { - let components = !{JSON.stringify(build.team.components)}; - let buildComponent = !{JSON.stringify(build.component)}; - let foundComponent = components.find(component => component._id === buildComponent); - document.getElementById("component-name").innerHTML = foundComponent.name; - } - setComponentName(); - - function loadScreenshots() { - let screenshots = !{JSON.stringify(screenshots)}; - screenshots.forEach((screenshot) => { - let img = document.getElementById(`screenshot_${screenshot._id}`); - if (img != undefined) { - let thumbnail = screenshot.thumbnail; - if (!thumbnail.startsWith('data:image')) { - thumbnail = `data:image/png;base64,${thumbnail}` - } - img.src = thumbnail; - } - }); - } - loadScreenshots(); - } - function toggleElement(identifier) { - let x = document.getElementById(identifier); - // x.classList.toggle("active") - if (x.style.display === 'none') { - x.style.display = 'contents'; - } else { - x.style.display = 'none'; - } - } - - body - div(class="container-div") - - // table containing build details - table(class="table") - thead(class="thead-dark") - tr - th(scope="col" colspan="100%" class="caps") - span= `Build Summary - ${build.name}` - span(class=`status-label status-${build.status}`)= build.status + +doctype html +html(lang="en") + head + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title= `Angles Report – ${build.name}` + style + include styles.css + + body + main.report + //- ── Build summary ───────────────────────────────────────────────────── + section.card.report-header(aria-labelledby="report-title") + .report-header-identity + span.report-header-icon.status-symbol( + class=`status-bg-${lower(build.status)} status-${lower(build.status)}` + aria-hidden="true" + ) + .report-header-text + .report-eyebrow Build report + h1.report-title#report-title= build.name + .report-header-meta + span.status-pill(class=`status-bg-${lower(build.status)}`)= build.status + if build.team + span= build.team.name + if component + span.meta-separator(aria-hidden="true") / + span= component.name + if build.environment + span.meta-separator(aria-hidden="true") · + span= build.environment.name + if build.phase + span.meta-separator(aria-hidden="true") · + span= build.phase.name + + dl.report-facts + .fact + dt Start + dd= formatDate(build.start) + .fact + dt Finish + dd= formatDate(build.end) + .fact + dt Duration + dd= formatDuration(build.start, build.end) + .fact + dt Suites + dd= (build.suites || []).length + .fact + dt Tests + dd= allExecutions.length + + .report-results + .result-counts + .result-count + span.result-count-value= buildCounts.total + span.result-count-label.muted Total + each status in STATUSES + .result-count + span.result-count-value(class=`status-${lower(status)}`)= buildCounts[status] + span.result-count-label(class=`status-${lower(status)}`)= STATUS_LABELS[status] + +resultsBar(buildCounts) + + //- ── Artifacts ───────────────────────────────────────────────────────── + if build.artifacts && build.artifacts.length + section.card.card-section(aria-labelledby="artifacts-title") + h2.card-title#artifacts-title= `Artifacts (${build.artifacts.length})` + table.data-table + thead + tr + th(scope="col") Group ID + th(scope="col") Artifact ID + th(scope="col") Version tbody + each artifact in build.artifacts tr - th(scope="row" ) Team - td(class="caps")= build.team.name - th(scope="row") Pass - td=Object.fromEntries(build.result).PASS - tr - th(scope="row") Component - td(id="component-name") - th(scope="row") Fail - td=Object.fromEntries(build.result).FAIL - tr - th(scope="row") Environment - td= build.environment.name - th(scope="row") Error - td=Object.fromEntries(build.result).ERROR - tr - th(scope="row") Start - td=moment(build.start).format('DD-MMM-YYYY HH:MM:SS') - th(scope="row") Skipped - td=Object.fromEntries(build.result).SKIPPED - tr - th(scope="row") Finish - td=moment(build.end).format('DD-MMM-YYYY HH:MM:SS') - th(scope="row") Duration (hh:mm:ss) - td=moment.utc(moment(build.end).diff(moment(build.start))).format("HH:mm:ss") - if (build.phase) - tr - th(scope="row") Phase - td= build.phase.name - th(scope="row") - td - - // add artifacts - if build.artifacts !== undefined - table(class="table") - thead(class="thead-dark") - tr - th(scope="col" colspan="100%")= `Build Artifacts (${build.artifacts.length})` - tbody - tr - th(scope="row") GroupId - th(scope="row") ArtifactId - th(scope="row") Version - each artifact, index in build.artifacts - tr - td= artifact.groupId - td= artifact.artifactId - td= artifact.version - - // tables containing suite details - each suite, index in build.suites - table(class="table suite-table") - thead(class="thead-dark") - tr - th(scope="col" class="caps") - span= `Suite: ${suite.name}` - span(class=`status-label status-${suite.status}`)= suite.status - th(scope="col" class="caps")= `Duration: ${moment.utc(moment(suite.end).diff(moment(suite.start))).format("HH:mm:ss")}` - th(scope="col")= `Total: ${suite.executions.length}` - th(scope="col")= `Pass: ${Object.fromEntries(suite.result).PASS}` - th(scope="col")= `Fail: ${Object.fromEntries(suite.result).FAIL}` - th(scope="col")= `Error: ${Object.fromEntries(suite.result).ERROR}` - th(scope="col")= `Skipped: ${Object.fromEntries(suite.result).SKIPPED}` - tbody - each execution, index in suite.executions - tr(class="test-row") - td(class=`${execution.status} caps` colspan="100%" onclick=`toggleElement("${execution._id}"); this.classList.toggle("opened");`)= `Test: ${execution.title}` - tr(class="actions-row" id=`${execution._id}` style={ display: "none" }) - td(colspan="100%") - table(class="actions-table") - tbody - each action, index in execution.actions - tr(class="action-description") - td(class=`${action.status}` colspan="100%" onclick=`toggleElement("${ execution._id}_action_${index}"); this.classList.toggle("opened");`)= action.name - tr(class="action-steps" id=`${execution._id}_action_${index}` style={ display: "none" }) - td(colspan="100%") - // add table now - table(id="steps-table" class="table") - thead - tr - th(scope="col")= "#" - th(scope="col")= "Time" - th(scope="col")= "Status" - th(scope="col")= "Step" - th(scope="col")= "Expected" - th(scope="col")= "Actual" - th(scope="col")= "Info" - th(scope="col")= "Screenshot" - tbody - each step, index in action.steps - tr - td= index + 1 - td= moment(step.timestamp).format("HH:mm:ss") - td(class=`step-${step.status}`)= step.status - if (step.status === 'INFO' || step.status === 'ERROR') - td(colspan="4")= step.info - else - td= step.name - td= step.expected - td= step.actual - td= step.info - if (step.screenshot) - td - img(id=`screenshot_${step.screenshot}`) - else - td - - a(class="angles-link" href="https://angleshq.github.io/" target="_blank")= "* This report was generated using the Angles dashboard." + td= artifact.groupId + td= artifact.artifactId + td= artifact.version + + //- ── Toolbar ─────────────────────────────────────────────────────────── + if allExecutions.length + .toolbar(role="toolbar" aria-label="Filter and expand tests") + .filter-group(role="group" aria-label="Filter by result") + button.filter-chip(type="button" data-status="ALL" aria-pressed="true") + | All + span.filter-chip-count= executionCounts.ALL + each status in STATUSES + if executionCounts[status] > 0 + button.filter-chip(type="button" data-status=status aria-pressed="false") + span.status-symbol(class=`status-${lower(status)}` aria-hidden="true") + | #{STATUS_LABELS[status]} + span.filter-chip-count= executionCounts[status] + input.toolbar-search#report-search( + type="search" + placeholder="Filter tests by name, suite, feature or tag" + aria-label="Filter tests by name, suite, feature or tag" + ) + .toolbar-actions + if executionCounts.FAIL + executionCounts.ERROR > 0 + button.btn-ghost#expand-failures(type="button") Show failures + button.btn-ghost#expand-all(type="button") Expand all + button.btn-ghost#collapse-all(type="button") Collapse all + + //- ── Suites ──────────────────────────────────────────────────────────── + each suite in (build.suites || []) + - const suiteCounts = toCounts(suite.result) + details.card.suite(open) + summary.suite-header + .suite-header-info + span.chevron(aria-hidden="true") + div + .suite-title= suite.name + .suite-meta + span.status-pill(class=`status-bg-${lower(suite.status)}`)= suite.status + span= plural((suite.executions || []).length, 'test') + span.meta-separator(aria-hidden="true") · + span= formatDuration(suite.start, suite.end) + .suite-results + +resultsBar(suiteCounts) + + .suite-body + each execution in (suite.executions || []) + - + const executionStatus = lower(execution.status); + const searchText = [execution.title, suite.name, execution.feature] + .concat(execution.tags || []).filter(Boolean).join(' ').toLowerCase(); + details.execution( + class=`execution-${executionStatus}` + data-status=execution.status + data-search=searchText + ) + summary.execution-header + span.chevron(aria-hidden="true") + .execution-main + .execution-title-line + span.execution-title= execution.title + span.status-chip.status-symbol(class=`status-${executionStatus}`)= execution.status + .execution-details + span= `Duration: ${formatDuration(execution.start, execution.end)}` + if execution.feature + span= `Feature: ${execution.feature}` + each platform in (execution.platforms || []) + span= describePlatform(platform) + if execution.tags && execution.tags.length + span + each tag in execution.tags + span.tag= tag + + .actions + if !execution.actions || !execution.actions.length + p.muted No actions were recorded for this test. + each action, actionIndex in (execution.actions || []) + - const actionStatus = lower(action.status) + - const failingAction = action.status === 'FAIL' || action.status === 'ERROR' + details.action(class=`action-${actionStatus}` open=failingAction) + summary.action-header + span.chevron(aria-hidden="true") + span.action-index= actionIndex + 1 + span.status-symbol(class=`status-${actionStatus}` aria-label=action.status) + span.action-name= action.name + span.action-step-count= plural((action.steps || []).length, 'step') + + ol.steps + each step, stepIndex in (action.steps || []) + - + const stepStatus = lower(step.status); + const isMessage = step.status === 'INFO' || step.status === 'ERROR' || step.status === 'DEBUG'; + const shot = step.screenshot ? screenshotsById[String(step.screenshot)] : undefined; + li.step(class=`step-${stepStatus}`) + span.step-index= stepIndex + 1 + span.step-status.status-symbol( + class=`status-${stepStatus}` + title=step.status + aria-label=step.status + ) + .step-main + .step-head + span.step-time= formatTime(step.timestamp) + if isMessage + span.step-name= step.info || step.name + else + span.step-name= step.name + if step.expected || step.actual + dl.step-diff + dt Expected + dd= step.expected || '—' + dt Actual + dd= step.actual || '—' + if !isMessage && step.info + .step-info= step.info + if shot && shot.src + button.step-shot(type="button" title="View screenshot") + img( + src=shot.src + alt=`Screenshot: ${shot.view || step.name || 'step ' + (stepIndex + 1)}` + loading="lazy" + ) + + if allExecutions.length + p.empty-state#report-empty(hidden) No tests match the current filter. + else + p.empty-state No tests were recorded for this build. + + footer.report-footer + | Generated #{moment().format('DD MMM YYYY, HH:mm')} by the + | + a(href="https://angleshq.github.io/" target="_blank" rel="noopener noreferrer") Angles dashboard + | . + + .viewer#screenshot-viewer(hidden tabindex="-1" role="dialog" aria-modal="true" aria-label="Screenshot") + img(alt="") + span.viewer-caption + + script + include report.js diff --git a/app/assets/report/report.js b/app/assets/report/report.js new file mode 100644 index 0000000..ae3f5f1 --- /dev/null +++ b/app/assets/report/report.js @@ -0,0 +1,112 @@ +/* eslint-env browser */ +/* + * Angles build report — client-side behaviour. + * + * Inlined into the report by index.pug, so the downloaded file works offline. Expanding + * and collapsing is native
/; this script only adds the bulk controls, + * the status / text filter, the screenshot viewer and print expansion. + */ +(() => { + const all = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + const setOpen = (elements, open) => elements.forEach((element) => { + if (open) element.setAttribute('open', ''); + else element.removeAttribute('open'); + }); + + const executions = all('.execution'); + const suites = all('.suite'); + const chips = all('.filter-chip'); + const search = document.getElementById('report-search'); + const emptyState = document.getElementById('report-empty'); + let activeStatus = 'ALL'; + + const applyFilter = () => { + const term = search ? search.value.trim().toLowerCase() : ''; + let visibleCount = 0; + executions.forEach((execution) => { + const statusMatch = activeStatus === 'ALL' || execution.dataset.status === activeStatus; + const textMatch = !term || execution.dataset.search.includes(term); + const visible = statusMatch && textMatch; + execution.classList.toggle('is-filtered-out', !visible); + if (visible) visibleCount += 1; + }); + suites.forEach((suite) => { + const hasVisible = suite.querySelector('.execution:not(.is-filtered-out)') !== null; + suite.classList.toggle('is-filtered-out', !hasVisible); + }); + if (emptyState) emptyState.hidden = visibleCount !== 0 || executions.length === 0; + }; + + chips.forEach((chip) => { + chip.addEventListener('click', () => { + activeStatus = chip.dataset.status; + chips.forEach((other) => other.setAttribute('aria-pressed', String(other === chip))); + applyFilter(); + }); + }); + + if (search) search.addEventListener('input', applyFilter); + + const onClick = (id, handler) => { + const element = document.getElementById(id); + if (element) element.addEventListener('click', handler); + }; + + onClick('expand-all', () => setOpen(all('.suite, .execution, .action'), true)); + onClick('collapse-all', () => setOpen(all('.execution, .action'), false)); + + // Open only what explains the failures: every failed/errored test and, inside it, the + // failed/errored actions. Everything else is closed so the failures stand out. + onClick('expand-failures', () => { + setOpen(all('.execution, .action'), false); + const failing = all('.execution-fail, .execution-error'); + setOpen(failing, true); + setOpen(failing.map((execution) => execution.closest('.suite')).filter(Boolean), true); + failing.forEach((execution) => setOpen(all('.action-fail, .action-error', execution), true)); + }); + + // ── Screenshot viewer ───────────────────────────────────────────────────── + const viewer = document.getElementById('screenshot-viewer'); + let lastTrigger = null; + + const closeViewer = () => { + if (!viewer || viewer.hidden) return; + viewer.hidden = true; + if (lastTrigger) lastTrigger.focus(); + }; + + if (viewer) { + const viewerImage = viewer.querySelector('img'); + const viewerCaption = viewer.querySelector('.viewer-caption'); + all('.step-shot').forEach((button) => { + button.addEventListener('click', () => { + const image = button.querySelector('img'); + if (!image) return; + lastTrigger = button; + viewerImage.src = image.src; + viewerImage.alt = image.alt; + viewerCaption.textContent = image.alt; + viewer.hidden = false; + viewer.focus(); + }); + }); + viewer.addEventListener('click', closeViewer); + } + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeViewer(); + }); + + // ── Print: expand everything, then restore what the reader had open ─────── + let openBeforePrint = null; + window.addEventListener('beforeprint', () => { + openBeforePrint = all('details').filter((details) => details.open); + setOpen(all('details'), true); + }); + window.addEventListener('afterprint', () => { + if (!openBeforePrint) return; + setOpen(all('details'), false); + setOpen(openBeforePrint, true); + openBeforePrint = null; + }); +})(); diff --git a/app/assets/report/styles.css b/app/assets/report/styles.css index 097aa55..9896d6c 100644 --- a/app/assets/report/styles.css +++ b/app/assets/report/styles.css @@ -1,156 +1,947 @@ -/* style.css */ -.caps { - text-transform: capitalize; +/* + * Angles build report. + * + * The report is downloaded and opened as a standalone file, so everything it needs is + * inlined: no CDN stylesheet, no web fonts, no external scripts. The tokens below mirror + * the Angles UI "Ember & Sand" design tokens (angles-ui/src/styles/tokens) so the report + * reads as part of the same product. Raw colour values live in the two primitive blocks + * only; every rule further down consumes the semantic tokens. + */ + +/* ── Primitives: Ember light (default) ─────────────────────────────────────── */ +:root { + --theme-ink-rgb: 0, 0, 0; + --theme-is-dark: 0; + + --master-color-1: hsl(40, 30%, 97%); + --master-color-2: hsl(40, 22%, 92%); + --master-color-2-relative: hsl(38, 16%, 80%); + + --master-font-color-1: hsl(20, 8%, 14%); + --master-font-color-2: hsl(40, 33%, 98%); + --master-font-color-3: hsl(30, 6%, 40%); + + --master-color-3: #ed8936; + --master-color-3-relative: #dd6b20; + --accent-rgb: 237, 137, 54; + + --pass-color: #5fad6f; + --fail-color: #fc6571; + --error-color: #f5b83f; + --skipped-color: #0ea5e9; +} + +/* ── Primitives: Ember dark (follows the viewer's OS preference) ───────────── */ +@media (prefers-color-scheme: dark) { + :root { + --theme-ink-rgb: 255, 255, 255; + --theme-is-dark: 1; + + --master-color-1: hsl(30, 8%, 17%); + --master-color-2: hsl(24, 9%, 12%); + --master-color-2-relative: hsl(30, 6%, 34%); + + --master-font-color-1: hsl(40, 30%, 93%); + --master-font-color-2: hsl(40, 33%, 97%); + --master-font-color-3: hsl(38, 12%, 76%); + + --master-color-3: #f6ad55; + --master-color-3-relative: #ed8936; + --accent-rgb: 246, 173, 85; + + --pass-color: #6cc07e; + --fail-color: #ff7b85; + --error-color: #f9c65b; + --skipped-color: #38bdf8; + } +} + +/* ── Semantic tokens ───────────────────────────────────────────────────────── */ +:root { + color-scheme: light dark; + + --color-primary: var(--master-color-3); + --color-primary-hover: var(--master-color-3-relative); + --color-primary-contrast: var(--master-font-color-2); + --link-color-1: var(--color-primary); + + --body-background: var(--master-color-1); + --surface-raised: var(--master-color-2); + --surface-sunken: var(--master-color-1); + + --main-panel-font-color: var(--master-font-color-1); + --main-panel-secondary-font-color: var(--master-font-color-3); + --suite-header-background: var(--master-color-3); + --suite-header-font-color: var(--master-font-color-2); + + --border-color: var(--master-color-2-relative); + --border-subtle: rgba(var(--theme-ink-rgb), 0.12); + --focus-ring: rgba(var(--accent-rgb), 0.45); + --card-shadow: 0 1px 3px rgba(16, 24, 40, calc(0.10 - 0.10 * var(--theme-is-dark))), + 0 1px 2px rgba(16, 24, 40, calc(0.06 - 0.06 * var(--theme-is-dark))); + --card-border-color: rgba(var(--theme-ink-rgb), calc(0.08 * var(--theme-is-dark))); + --shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.10), 0 1px 2px rgba(16, 24, 40, 0.06); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 999px; + + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-md: 1rem; + --font-size-xl: 1.25rem; + --font-size-3xl: 1.875rem; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --line-height-tight: 1.2; + --line-height-normal: 1.5; + + --transition-fast: 120ms cubic-bezier(0.2, 0, 0, 1); +} + +/* ── Base ──────────────────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background-color: var(--body-background); + color: var(--main-panel-font-color); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-size: var(--font-size-md); + line-height: var(--line-height-normal); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--link-color-1); + text-decoration: none; +} + +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +summary { + list-style: none; + cursor: pointer; +} + +summary::-webkit-details-marker { + display: none; +} + +summary:focus-visible, +button:focus-visible, +input:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.report { + max-width: 1280px; + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-6); +} + +.card { + background-color: var(--surface-raised); + border: 1px solid var(--card-border-color); + border-radius: var(--radius-lg); + box-shadow: var(--card-shadow); } -.container-div { - max-width: 80%; - margin: auto; - padding: 30px 0px; +.card + .card, +.card + .toolbar, +.toolbar + .suite { + margin-top: var(--space-4); } -.suite-table > tbody:before { - line-height: 1em; - content: "_"; - color: white; + +.muted { + color: var(--main-panel-secondary-font-color); +} + +/* ── Status colours (mirror components.less) ───────────────────────────────── */ +.status-pass { color: var(--pass-color); } +.status-fail { color: var(--fail-color); } +.status-error { color: var(--error-color); } +.status-skipped, +.status-info, +.status-debug { color: var(--skipped-color); } + +.status-bg-pass { background-color: var(--pass-color); } +.status-bg-fail { background-color: var(--fail-color); } +.status-bg-error { background-color: var(--error-color); } +.status-bg-skipped { background-color: var(--skipped-color); } + +/* Symbols carry the status as well as colour, as the accessible UI themes do. */ +.status-symbol::before { display: inline-block; width: 1em; text-align: center; } +.status-symbol.status-pass::before { content: '\2713'; } +.status-symbol.status-fail::before { content: '\2717'; } +.status-symbol.status-error::before { content: '!'; } +.status-symbol.status-skipped::before { content: '\2013'; } +.status-symbol.status-info::before { content: 'i'; font-style: italic; } +.status-symbol.status-debug::before { content: '\2022'; } + +/* Filled pill used for build / suite status. */ +.status-pill { + display: inline-flex; + align-items: center; + padding: 2px var(--space-3); + border-radius: var(--radius-pill); + color: var(--master-font-color-2); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +/* Tinted chip used for execution status (matches .execution-status-chip). */ +.status-chip { + flex: 0 0 auto; + padding: 1px var(--space-2); + border: 1px solid currentcolor; + border-radius: var(--radius-pill); + background-color: color-mix(in srgb, currentcolor 12%, transparent); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Results bar ───────────────────────────────────────────────────────────── */ +.results-bar { + display: flex; + width: 100%; + height: 10px; + overflow: hidden; + border-radius: var(--radius-pill); + background-color: var(--border-subtle); +} + +.results-bar-segment { + min-width: 3px; +} + +.results-bar-segment + .results-bar-segment { + border-left: 2px solid var(--surface-raised); +} + +/* ── Header card ───────────────────────────────────────────────────────────── */ +.report-header-identity { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); +} + +.report-header-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 48px; + height: 48px; + border-radius: 50%; + color: var(--master-font-color-2); + font-size: 26px; + font-weight: var(--font-weight-bold); +} + +.report-header-icon.status-symbol::before { + width: auto; +} + +.report-header-text { + flex: 1 1 auto; + min-width: 0; +} + +.report-eyebrow { + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.report-title { + margin: 0; + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + overflow-wrap: anywhere; +} + +.report-header-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + margin-top: var(--space-2); + font-size: var(--font-size-sm); +} + +.meta-separator { + opacity: 0.5; +} + +.report-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-4); + margin: 0; + padding: var(--space-4) var(--space-5); + border-top: 1px solid var(--border-subtle); +} + +.fact dt { + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.fact dd { + margin: var(--space-1) 0 0; + font-weight: var(--font-weight-medium); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.report-results { + padding: var(--space-4) var(--space-5) var(--space-5); + border-top: 1px solid var(--border-subtle); +} + +.result-counts { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.result-count { + padding: var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background-color: var(--surface-sunken); +} + +.result-count-value { display: block; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + font-variant-numeric: tabular-nums; + line-height: var(--line-height-tight); } -.status-label { - display: inline-block; - margin-left: 10px; - padding: 2px 10px; - border-radius: 10px; - font-size: 12px; - font-weight: bold; - letter-spacing: 0.5px; +.result-count-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* ── Artifacts card ────────────────────────────────────────────────────────── */ +.card-section { + padding: var(--space-4) var(--space-5); +} + +.card-title { + margin: 0 0 var(--space-3); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.data-table th { + padding: var(--space-2) var(--space-3); + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + text-align: left; text-transform: uppercase; - vertical-align: middle; - color: white; + border-bottom: 1px solid var(--border-color); } -.status-PASS { - background-color: #98dc79; +.data-table td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--border-subtle); + overflow-wrap: anywhere; } -.status-FAIL { - background-color: #c61410; +.data-table tr:last-child td { + border-bottom: none; } -.status-ERROR { - background-color: #e8a33d; +/* ── Toolbar ───────────────────────────────────────────────────────────────── */ +.toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); } -.status-SKIPPED { - background-color: #b0b0b0; +.filter-group { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-1); + padding: var(--space-1); + border-radius: var(--radius-pill); + background-color: var(--surface-raised); + border: 1px solid var(--border-subtle); } -.test-row { - font-weight: bold; - line-height: 1.5em; - border-bottom: solid white 5px; +.filter-chip, +.btn-ghost { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-pill); + background: transparent; + color: var(--main-panel-font-color); + font: inherit; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast), + border-color var(--transition-fast); } -.test-row td { - padding-bottom: 5px; - padding-top: 5px; - padding-left: 10px; +.filter-chip:hover, +.btn-ghost:hover { + background-color: var(--surface-sunken); } -.test-row .PASS { - color: white; - background-color: #98dc79; +.filter-chip[aria-pressed='true'] { + background-color: var(--color-primary); + color: var(--color-primary-contrast); } -.test-row .FAIL { - color: white; - background-color: #c61410; +.filter-chip-count { + font-variant-numeric: tabular-nums; + opacity: 0.75; } -.actions-table { - width: 100%; +.btn-ghost { + border-color: var(--color-primary); + color: var(--color-primary); } -.actions-table td { - border-top: none !important; +.btn-ghost:hover { + color: var(--color-primary-hover); + border-color: var(--color-primary-hover); } -#steps-table tbody{ - background-color: #e8edff; - font-weight: 500; +.toolbar-search { + flex: 1 1 220px; + min-width: 0; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border-color); + border-radius: var(--radius-pill); + background-color: var(--surface-raised); + color: var(--main-panel-font-color); + font: inherit; + font-size: var(--font-size-sm); } -#steps-table > thead { - background-color: #b9c9fe; +.toolbar-actions { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-2); } -#steps-table th:first-child { - border-top-left-radius: 10px; +.empty-state { + margin-top: var(--space-4); + padding: var(--space-3) var(--space-4); + border-left: 4px solid var(--skipped-color); + border-radius: var(--radius-md); + background-color: var(--surface-raised); } -#steps-table th:last-child { - border-top-right-radius: 10px; +/* ── Chevron shared by every expandable level ──────────────────────────────── */ +.chevron { + flex: 0 0 auto; + width: 8px; + height: 8px; + margin: 0 var(--space-1); + border-right: 2px solid currentcolor; + border-bottom: 2px solid currentcolor; + transform: rotate(-45deg); + transition: transform var(--transition-fast); + opacity: 0.8; } -#steps-table tr:last-child td:first-child { - border-bottom-left-radius: 10px; +details[open] > summary > .chevron { + transform: rotate(45deg); } -#steps-table tr:last-child td:last-child { - border-bottom-right-radius: 10px; +/* ── Suite ─────────────────────────────────────────────────────────────────── */ +.suite { + overflow: hidden; } -.step-PASS { - font-weight: bold; - color: #98dc79; +.suite + .suite { + margin-top: var(--space-4); } -.step-FAIL { - font-weight: bold; - color: #c61410; +.suite-header { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + background-color: var(--suite-header-background); + color: var(--suite-header-font-color); } -.action-description { - font-weight: bold; - background: white; - cursor: pointer; +.suite-header-info { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 1 1 auto; + min-width: 0; } -.actions-row { - width: 100%; +.suite-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + overflow-wrap: anywhere; } -.test-row > td:after { - content: '\02795'; /* Unicode character for "minus" sign (-) */ - font-size: 13px; - color: white; - float: right; - margin-left: 5px; +.suite-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); + margin-top: var(--space-1); + font-size: var(--font-size-sm); + opacity: 0.9; } -.test-row > .opened:after { - content: '\2796'; +.suite-results { + flex: 0 0 auto; + width: 240px; } -.action-description > td:after { - content: '\02795'; /* Unicode character for "minus" sign (-) */ - font-size: 13px; - color: white; - float: right; - margin-left: 5px; +.suite-results .results-bar { + height: 12px; + background-color: color-mix(in srgb, var(--suite-header-font-color) 30%, transparent); } -.action-description > .opened:after { - content: '\2796'; +.suite-results .results-bar-segment + .results-bar-segment { + border-left-color: var(--suite-header-background); } -.angles-link { - font-size: 13px; - color: #c38541; - font-weight: 500; +.suite-header .status-pill { + border: 1px solid color-mix(in srgb, var(--suite-header-font-color) 60%, transparent); } -.angles-link:hover { - color: #ee8308; - text-decoration: none; +.suite-body { + padding: var(--space-3) var(--space-4) var(--space-4); + background-color: var(--body-background); +} + +/* ── Execution ─────────────────────────────────────────────────────────────── */ +.execution { + border: 1px solid var(--border-subtle); + border-left: 4px solid var(--border-color); + border-radius: var(--radius-md); + background-color: var(--surface-raised); + overflow: hidden; + transition: box-shadow var(--transition-fast); +} + +.execution + .execution { + margin-top: var(--space-2); +} + +.execution[open] { + box-shadow: var(--shadow-sm); +} + +.execution-pass { border-left-color: var(--pass-color); } +.execution-fail { border-left-color: var(--fail-color); } +.execution-error { border-left-color: var(--error-color); } +.execution-skipped { border-left-color: var(--skipped-color); } + +.execution-header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + transition: background-color var(--transition-fast); +} + +.execution-header:hover { + background-color: var(--surface-sunken); +} + +.execution-main { + flex: 1 1 auto; + min-width: 0; +} + +.execution-title-line { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.execution-title { + font-weight: var(--font-weight-semibold); + overflow-wrap: anywhere; +} + +.execution-details { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-4); + margin-top: var(--space-1); + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); +} + +.tag { + display: inline-block; + padding: 0 var(--space-2); + border-radius: var(--radius-pill); + background-color: var(--surface-sunken); + border: 1px solid var(--border-subtle); + font-size: var(--font-size-xs); +} + +.tag + .tag { + margin-left: var(--space-1); +} + +/* ── Actions ───────────────────────────────────────────────────────────────── */ +.actions { + padding: var(--space-2) var(--space-3) var(--space-3) var(--space-6); + background-color: var(--body-background); + border-top: 1px solid var(--border-subtle); +} + +.action { + padding-left: var(--space-3); + border-left: 2px solid var(--border-subtle); +} + +.action + .action { + margin-top: var(--space-1); +} + +.action-fail { border-left-color: var(--fail-color); } +.action-error { border-left-color: var(--error-color); } + +.action-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border-radius: var(--radius-sm); + transition: background-color var(--transition-fast); +} + +.action-header:hover { + background-color: var(--surface-raised); +} + +.action-index { + flex: 0 0 auto; + min-width: 20px; + padding: 0 var(--space-1); + border-radius: var(--radius-xs); + background-color: var(--surface-raised); + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.action-name { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.action-step-count { + flex: 0 0 auto; + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); +} + +/* ── Steps ─────────────────────────────────────────────────────────────────── */ +.steps { + margin: var(--space-1) 0 var(--space-2) var(--space-3); + padding: 0; + list-style: none; + border-radius: var(--radius-sm); + background-color: var(--surface-raised); + border: 1px solid var(--border-subtle); +} + +.step { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); +} + +.step + .step { + border-top: 1px solid var(--border-subtle); +} + +.step-fail, +.step-error { + background-color: color-mix(in srgb, var(--fail-color) 6%, transparent); +} + +.step-index { + flex: 0 0 auto; + min-width: 1.5em; + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.step-status { + flex: 0 0 auto; + font-weight: var(--font-weight-bold); +} + +.step-main { + flex: 1 1 auto; + min-width: 0; +} + +.step-head { + display: flex; + align-items: baseline; + gap: var(--space-3); +} + +.step-time { + flex: 0 0 auto; + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; +} + +.step-name { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.step-info { + margin-top: var(--space-1); + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.step-diff { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-1) var(--space-3); + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background-color: var(--surface-sunken); + font-size: var(--font-size-sm); +} + +.step-fail .step-diff { + background-color: color-mix(in srgb, var(--fail-color) 10%, transparent); +} + +.step-diff dt { + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.step-diff dd { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.step-shot { + flex: 0 0 auto; + padding: 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: none; + cursor: zoom-in; + overflow: hidden; + transition: box-shadow var(--transition-fast); +} + +.step-shot:hover { + box-shadow: var(--shadow-sm); +} + +.step-shot img { + display: block; + height: 56px; + width: auto; + max-width: 96px; + object-fit: cover; +} + +/* ── Screenshot viewer ─────────────────────────────────────────────────────── */ +.viewer { + position: fixed; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-5); + background-color: rgba(0, 0, 0, calc(0.6 + 0.2 * var(--theme-is-dark))); + cursor: zoom-out; +} + +.viewer[hidden] { + display: none; +} + +.viewer img { + max-width: 100%; + max-height: calc(100% - 3em); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + background-color: var(--surface-raised); +} + +.viewer-caption { + color: var(--master-font-color-2); + font-size: var(--font-size-sm); +} + +/* ── Footer ────────────────────────────────────────────────────────────────── */ +.report-footer { + margin-top: var(--space-5); + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + text-align: center; +} + +/* Filtering hides rows rather than removing them, so expand state survives. */ +.is-filtered-out { + display: none; +} + +/* ── Narrow screens ────────────────────────────────────────────────────────── */ +@media (max-width: 720px) { + .report-header-identity, + .report-facts, + .report-results, + .card-section { + padding-left: var(--space-4); + padding-right: var(--space-4); + } + + .report-title { + font-size: var(--font-size-xl); + } + + .result-counts { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .suite-header { + flex-wrap: wrap; + } + + .suite-results { + width: 100%; + } + + .actions { + padding-left: var(--space-3); + } + + .step { + flex-wrap: wrap; + gap: var(--space-2); + } + + /* Keep the step text full width; the screenshot drops below it, aligned with the text. */ + .step-main { + flex-basis: calc(100% - 2.5em - 2 * var(--space-2)); + } + + .step-shot { + margin-left: calc(2.5em + 2 * var(--space-2)); + } + + .step-diff { + grid-template-columns: 1fr; + } + + .step-diff dd + dt { + margin-top: var(--space-1); + } + + .step-head { + flex-direction: column; + gap: 0; + } +} + +/* ── Print: the script expands everything before printing; drop the chrome. ── */ +@media print { + body { + background: none; + } + + .toolbar, + .viewer { + display: none; + } + + .card, + .execution { + box-shadow: none; + break-inside: avoid; + } } diff --git a/test/build.tests.js b/test/build.tests.js index 24504f8..adc14ce 100644 --- a/test/build.tests.js +++ b/test/build.tests.js @@ -369,8 +369,11 @@ describe('Build API Tests', () => { .expect(200) .end((err, res) => { if (err) return done(err); - res.text.should.containEql(''); + res.text.should.startWith(''); res.text.should.containEql('build-unit-testing-build'); + // The report is saved and opened offline, so it must not load anything remotely. + res.text.should.not.match(/]+stylesheet/); + res.text.should.not.match(/]+src=/); return done(); }); }); From b4b7150bb0b90addbb20a836d9879a80dbe84cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:26:20 +0000 Subject: [PATCH 2/2] fix(report): readable suite header text in dark mode Cream text on the orange-400 suite header measured 1.8:1 in dark mode. Use the dark panel ink there instead (8.7:1), through the same optional --master-font-color-accent-heading primitive that Angles UI now uses for its suite header (AnglesHQ/angles-ui, branch claude/angles-dashboard-sdet-features-g34tzr). Light mode is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf --- app/assets/report/styles.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/assets/report/styles.css b/app/assets/report/styles.css index 9896d6c..0ab485b 100644 --- a/app/assets/report/styles.css +++ b/app/assets/report/styles.css @@ -49,6 +49,9 @@ --master-color-3-relative: #ed8936; --accent-rgb: 246, 173, 85; + /* Cream on orange-400 is 1.8:1, so the suite header uses the panel ink (8.7:1). */ + --master-font-color-accent-heading: var(--master-color-2); + --pass-color: #6cc07e; --fail-color: #ff7b85; --error-color: #f9c65b; @@ -72,7 +75,7 @@ --main-panel-font-color: var(--master-font-color-1); --main-panel-secondary-font-color: var(--master-font-color-3); --suite-header-background: var(--master-color-3); - --suite-header-font-color: var(--master-font-color-2); + --suite-header-font-color: var(--master-font-color-accent-heading, var(--master-font-color-2)); --border-color: var(--master-color-2-relative); --border-subtle: rgba(var(--theme-ink-rgb), 0.12);