/**
* Renders the run report as a single self-contained page.
*
* Written for someone who did not run the tests: a verdict first, then a
* summary by capability, then the detail. Numbered sections so it can be cited
* ("2.3.1 failed"), and a print stylesheet so it can be saved as a PDF.
*/
import {
ApiCall,
Area,
RunMeta,
Scenario,
Step,
Summary,
areaFor,
escapeHtml,
formatValue,
friendly,
group,
label,
sentenceCase,
statusClass,
statusWord,
} from './report-model';
interface AreaBlock {
index: number;
area: Area;
items: Scenario[];
passed: number;
failed: number;
}
export function renderHtml(summary: Summary, scenarios: Scenario[], meta: RunMeta): string {
const areas: AreaBlock[] = group(scenarios).map((entry, index) => ({
index: index + 1,
area: areaFor(entry.name),
items: entry.items,
passed: entry.items.filter((s) => s.status === 'passed').length,
failed: entry.items.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
}));
const allPassed = summary.failed === 0 && summary.passed > 0;
const stamp = new Date(summary.generatedAt).toLocaleString('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
});
const contents = areas
.map(
(a) =>
`
2.${a.index} ${escapeHtml(
a.area.title,
)}${a.items.length} tests `,
)
.join('');
const summaryRows = areas
.map((a) => {
const rate = Math.round((a.passed / Math.max(a.items.length, 1)) * 100);
return `
2.${a.index}
${escapeHtml(a.area.title)}
${a.items.length}
${a.passed}
${a.failed || '—'}
${a.failed ? 'Failed' : 'Passed'}
`;
})
.join('');
return `
TravelHub — Test Execution Report
Test execution report
TravelHub
Multi-tenant flight booking · end-to-end suite ${escapeHtml(stamp)}
${allPassed ? '✓' : '!'}
${
allPassed ? 'All tests passed' : `${summary.failed} of ${summary.total} tests failed`
}
${summary.passed} of ${summary.total} scenarios verified in ${friendly(
summary.durationMs,
)}
Application under test ${escapeHtml(meta.baseURL)}
Browsers ${escapeHtml(meta.projects.join(', ') || 'chromium')}
Scenarios executed ${summary.total}
Duration ${friendly(summary.durationMs)}
Screenshots captured ${summary.screenshots}
API calls captured ${summary.apiCalls}
Scenarios with recorded data ${summary.narrated}
1
Summary
Each area below is a capability of the product rather than a source file. Every
scenario was executed against the running application and a real database, except
where an area states that the backend was simulated.
Area tested Tests
Passed Failed
Pass rate Result
${summaryRows}
Total
${summary.total}
${summary.passed}
${summary.failed || '—'}
${allPassed ? 'Passed' : 'Failed'}
Contents
${contents}
2
Detailed results
Scenarios shown with a data table carry the figures the test actually observed —
fares, booking references, seat counts — next to a screenshot of the screen at that
moment. The rest carry a screenshot of their final state.
All scenarios
With recorded data only
Expand all
Collapse all
Save as PDF
Passed
Failed
Not run
${areas.map(renderArea).join('')}
Generated automatically by the TravelHub end-to-end suite
(npm run test:e2e) and reopened with npm run report.
Screenshots are taken at the moment each step completed; click one to enlarge.
Use your browser’s print function to save this report as a PDF.
`;
}
function renderArea(entry: AreaBlock): string {
const scenarios = entry.items
.map((scenario, i) => renderScenario(scenario, `2.${entry.index}.${i + 1}`))
.join('');
return `
2.${entry.index}
${escapeHtml(entry.area.title)}
${entry.passed}/${entry.items.length} passed
${escapeHtml(entry.area.blurb)}
${scenarios}
`;
}
function renderScenario(scenario: Scenario, ref: string): string {
const hasFacts = scenario.steps.some((s) => s.facts);
const hasAnything = scenario.steps.length > 0 || scenario.apiCalls.length > 0;
const steps = scenario.steps.map(renderStep).join('');
const emptyNote = hasAnything
? ''
: 'No evidence was captured for this scenario.
';
return `
${ref}
${scenario.suite ? `${escapeHtml(scenario.suite)} ` : ''}
${escapeHtml(sentenceCase(scenario.title))}
${escapeHtml(scenario.project)}
${scenario.apiCalls.length} API
${friendly(scenario.durationMs)}
${statusWord(scenario.status)}
${scenario.error ? `
${escapeHtml(scenario.error)} ` : ''}
${steps}
${emptyNote}
${renderApiCalls(scenario.apiCalls)}
`;
}
/** The API traffic block: collapsed by default so it never dominates. */
function renderApiCalls(calls: ApiCall[]): string {
if (calls.length === 0) {
return '';
}
const rows = calls.map(renderApiCall).join('');
return `
API calls · ${calls.length} request${calls.length === 1 ? '' : 's'}
${rows}
`;
}
function renderApiCall(call: ApiCall): string {
const cls = call.status >= 500 ? 'c5' : call.status >= 400 ? 'c4' : call.status >= 300 ? 'c3' : 'c2';
const headerRows = Object.entries(call.requestHeaders)
.map(([k, v]) => `${escapeHtml(k)} ${escapeHtml(v)} `)
.join('');
const request = call.requestBody
? `Request payload ${escapeHtml(call.requestBody)} `
: '';
const response = call.responseBody
? `Response body${call.truncated ? ' (truncated)' : ''} ${escapeHtml(call.responseBody)} `
: '';
return `
${escapeHtml(call.method)}
${escapeHtml(call.path)}
${call.status} ${escapeHtml(call.statusText)}
${call.durationMs} ms
${request}
${response}
`;
}
function renderStep(step: Step): string {
const facts = step.facts
? `${Object.entries(step.facts)
.map(
([key, value]) =>
`${escapeHtml(label(key))} ${escapeHtml(formatValue(value))} `,
)
.join('')}
`
: '';
const shot = step.shot
? `
Screen at this point · click to enlarge
`
: '';
return `
${escapeHtml(step.seq)} ${escapeHtml(step.name)}
${facts}
${shot}
`;
}
const STYLES = `
:root {
--ink:#111827; --body:#374151; --muted:#6b7280; --faint:#9ca3af;
--line:#e5e7eb; --rule:#d1d5db; --bg:#f3f4f6; --card:#fff;
--pass:#067a6e; --pass-bg:#e6f4f1; --fail:#b3243f; --fail-bg:#fdeaee;
--skip:#6b7280; --skip-bg:#f3f4f6; --accent:#0b5cd6;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--body);
font:15px/1.6 'Inter',-apple-system,'Segoe UI',Roboto,sans-serif}
.sheet{max-width:1080px;margin:0 auto;background:var(--card);
box-shadow:0 1px 3px rgba(0,0,0,.08)}
h1,h2,h3,h4{color:var(--ink);margin:0;line-height:1.3}
a{color:var(--accent)}
.cover{padding:44px 56px 36px;border-bottom:3px solid var(--ink)}
.eyebrow{font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;
color:var(--faint);font-weight:650;margin-bottom:10px}
.cover h1{font-size:2.05rem;letter-spacing:-.025em;margin-bottom:6px}
.cover .when{color:var(--muted);font-size:.92rem;margin:0}
.verdict{display:flex;align-items:center;gap:18px;margin-top:28px;padding:20px 24px;
border-radius:10px;border:1px solid}
.verdict.ok{background:var(--pass-bg);border-color:#b9e0d8}
.verdict.bad{background:var(--fail-bg);border-color:#f3c2cc}
.verdict .mark{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;
font-size:1.3rem;color:#fff;flex-shrink:0}
.verdict.ok .mark{background:var(--pass)}
.verdict.bad .mark{background:var(--fail)}
.verdict .headline{font-size:1.16rem;font-weight:650;color:var(--ink)}
.verdict .sub{font-size:.9rem;color:var(--muted)}
.facts-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));
border-top:1px solid var(--line);margin:28px 0 0}
.facts-grid div{padding:14px 24px 14px 0;border-bottom:1px solid var(--line)}
.facts-grid dt{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--faint);font-weight:650;margin-bottom:3px}
.facts-grid dd{margin:0;font-size:.92rem;color:var(--ink)}
section{padding:36px 56px}
section + section{border-top:1px solid var(--line)}
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:6px;flex-wrap:wrap}
.section-title .num{color:var(--faint);font-variant-numeric:tabular-nums;
font-weight:650;font-size:1.05rem}
.section-title h2{font-size:1.3rem;letter-spacing:-.015em}
.area-title{font-size:1.12rem}
.lede{color:var(--muted);font-size:.93rem;margin:0 0 22px;max-width:74ch}
.area-blurb{margin:8px 0 0}
.sub-head{margin-top:30px;font-size:.95rem}
.toc{list-style:none;padding:0;margin:10px 0 0;max-width:640px}
.toc li{display:flex;align-items:baseline;gap:8px;padding:7px 0;
border-bottom:1px dotted var(--rule)}
.toc a{text-decoration:none;color:var(--ink)}
.toc a:hover{color:var(--accent)}
.toc .num{color:var(--faint);font-variant-numeric:tabular-nums;margin-right:10px}
.toc .dots{flex:1}
.toc .count{color:var(--faint);font-size:.85rem;font-variant-numeric:tabular-nums}
table.summary{width:100%;border-collapse:collapse;font-size:.92rem}
table.summary th{text-align:left;font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--faint);font-weight:650;padding:0 12px 8px 0;
border-bottom:2px solid var(--rule)}
table.summary td{padding:11px 12px 11px 0;border-bottom:1px solid var(--line);
vertical-align:middle}
table.summary td a{text-decoration:none;font-weight:550}
.num-cell{color:var(--faint);font-variant-numeric:tabular-nums;width:48px}
.figure{font-variant-numeric:tabular-nums;text-align:right;width:70px}
.bar{width:110px;height:7px;background:var(--line);border-radius:99px;overflow:hidden}
.bar span{display:block;height:100%;background:var(--pass);border-radius:99px}
.tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
text-transform:uppercase;padding:3px 9px;border-radius:4px;white-space:nowrap}
.tag.pass{background:var(--pass-bg);color:var(--pass)}
.tag.fail{background:var(--fail-bg);color:var(--fail)}
.tag.skip{background:var(--skip-bg);color:var(--skip)}
tfoot td{font-weight:650;color:var(--ink);border-bottom:none;padding-top:14px}
.area{padding-top:34px}
.area:first-of-type{padding-top:0}
.area-head{border-left:3px solid var(--accent);padding-left:16px;margin-bottom:20px}
.scenario{border:1px solid var(--line);border-radius:8px;margin-bottom:14px;
overflow:hidden;background:var(--card)}
.scenario > summary{list-style:none;cursor:pointer;padding:13px 16px;display:flex;
gap:14px;align-items:flex-start}
.scenario > summary::-webkit-details-marker{display:none}
.scenario > summary:hover{background:#fafafa}
.ref{color:var(--faint);font-variant-numeric:tabular-nums;font-size:.85rem;
min-width:52px;flex-shrink:0;padding-top:2px}
.headline{flex:1;min-width:0}
.headline .what{font-weight:600;color:var(--ink)}
.headline .group{display:block;font-size:.78rem;color:var(--muted);margin-bottom:1px}
.right{display:flex;align-items:center;gap:10px;flex-shrink:0;
font-size:.78rem;color:var(--faint)}
.body{border-top:1px solid var(--line);padding:0 16px 8px;background:#fcfcfd}
.step{padding:18px 0;border-bottom:1px dashed var(--line)}
.step:last-child{border-bottom:none}
.step h4{font-size:.95rem;margin-bottom:10px;display:flex;gap:9px;align-items:baseline}
.step h4 .n{color:var(--faint);font-variant-numeric:tabular-nums;font-weight:650;font-size:.8rem}
table.facts{border-collapse:collapse;font-size:.86rem;margin:0 0 12px;min-width:340px}
table.facts td{border:1px solid var(--line);padding:6px 11px}
table.facts td:first-child{background:#f8fafc;color:var(--muted);white-space:nowrap;
font-size:.8rem}
table.facts td:last-child{font-variant-numeric:tabular-nums;color:var(--ink);font-weight:500}
figure{margin:0}
.shot{display:block;max-width:100%;border:1px solid var(--rule);border-radius:6px;
cursor:zoom-in}
figcaption{font-size:.75rem;color:var(--faint);margin-top:5px}
.no-evidence{color:var(--faint);font-size:.86rem;padding:14px 0}
.error{background:var(--fail-bg);color:#8c1030;padding:11px 14px;margin:12px 0;
border-radius:6px;font-size:.83rem;white-space:pre-wrap;
font-family:ui-monospace,monospace}
/* --- API traffic --- */
details.api{border-top:1px solid var(--line);margin-top:6px}
details.api > summary{list-style:none;cursor:pointer;padding:12px 0 10px;font-size:.78rem;
font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
details.api > summary::-webkit-details-marker{display:none}
details.api > summary::before{content:'\\25B8 ';color:var(--faint)}
details.api[open] > summary::before{content:'\\25BE '}
.api-list{display:flex;flex-direction:column;gap:10px;padding-bottom:6px}
.call{border:1px solid var(--line);border-radius:7px;overflow:hidden;background:#fff}
.call-head{display:flex;align-items:center;gap:10px;padding:8px 11px;background:#f8fafc;
flex-wrap:wrap;font-size:.82rem}
.method{font-weight:700;font-size:.7rem;letter-spacing:.04em;padding:2px 7px;border-radius:4px;
background:#e5e7eb;color:#374151}
.method.m-get{background:#e0edff;color:#0b5cd6}
.method.m-post{background:#e6f4f1;color:#067a6e}
.method.m-patch,.method.m-put{background:#fef3c7;color:#b45309}
.method.m-delete{background:#fdeaee;color:#b3243f}
.call-path{font-family:ui-monospace,Menlo,monospace;font-size:.8rem;color:var(--ink);
word-break:break-all;flex:1;min-width:120px}
.status{font-weight:700;font-size:.76rem;font-variant-numeric:tabular-nums}
.status.c2{color:var(--ok)} .status.c3{color:var(--med)}
.status.c4{color:var(--fail)} .status.c5{color:#fff;background:var(--fail);padding:1px 6px;border-radius:4px}
.call-time{color:var(--faint);font-size:.76rem;font-variant-numeric:tabular-nums}
.part{padding:8px 11px;border-top:1px solid var(--line)}
.part-label{display:block;font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;
color:var(--faint);font-weight:650;margin-bottom:5px}
.call pre{background:#0f172a;color:#e2e8f0;padding:10px 12px;border-radius:6px;overflow-x:auto;
font:12px/1.5 ui-monospace,Menlo,monospace;margin:0;max-height:340px}
details.headers{padding:8px 11px;border-top:1px solid var(--line)}
details.headers > summary{cursor:pointer;font-size:.72rem;color:var(--muted)}
details.headers table{border-collapse:collapse;font-size:.76rem;margin-top:6px}
details.headers td{border:1px solid var(--line);padding:3px 8px;
font-family:ui-monospace,Menlo,monospace;word-break:break-all}
details.headers td:first-child{color:var(--muted);background:#f8fafc}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:20px}
.toolbar button{border:1px solid var(--rule);background:#fff;border-radius:6px;
padding:6px 13px;font:inherit;font-size:.85rem;cursor:pointer;color:var(--body)}
.toolbar button:hover{border-color:var(--accent);color:var(--accent)}
.toolbar button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:.8rem;color:var(--muted);margin-left:auto}
.legend span{display:inline-flex;align-items:center;gap:6px}
.swatch{width:10px;height:10px;border-radius:2px;display:inline-block}
.swatch.pass{background:var(--pass)}
.swatch.fail{background:var(--fail)}
.swatch.skip{background:var(--skip)}
footer{padding:26px 56px 44px;color:var(--faint);font-size:.82rem;
border-top:1px solid var(--line)}
footer code{background:var(--bg);padding:1px 5px;border-radius:3px}
dialog{border:none;border-radius:10px;padding:0;max-width:96vw;max-height:96vh}
dialog::backdrop{background:rgba(17,24,39,.8)}
dialog img{display:block;max-width:96vw;max-height:96vh}
@media (max-width:720px){
.cover{padding:28px 20px 24px}
section{padding:26px 20px}
footer{padding:22px 20px 34px}
.hide-narrow{display:none}
.scenario > summary{flex-wrap:wrap}
table.summary{font-size:.85rem}
.bar{width:60px}
}
@media print{
body{background:#fff;font-size:11pt}
.sheet{box-shadow:none;max-width:none}
.toolbar,dialog{display:none !important}
/* The page margins come from the printer, so drop the screen gutters. */
.cover{padding:0 0 22px}
section{padding:22px 0}
footer{padding:16px 0 0}
.area{break-before:page}
.area:first-of-type{break-before:auto}
.area-head{break-after:avoid}
.scenario{break-inside:avoid;border-color:var(--rule)}
.scenario > summary{background:#fff}
.step{break-inside:avoid}
h2,h3,h4{break-after:avoid}
table.summary{break-inside:avoid}
/* A closed prints nothing, which would silently drop most of the
report. The beforeprint handler opens them; this is the belt and braces. */
.scenario:not([open]) > .body{display:block}
.shot{max-height:150mm;object-fit:contain;object-position:left top}
a,a:visited,table.summary td a,.toc a{color:var(--ink);text-decoration:none}
.toc .count,.right{color:var(--muted)}
.hint{display:none} /* nothing to click on paper */
}`;
const SCRIPT = `
const box = document.getElementById('lightbox');
const img = box.querySelector('img');
document.addEventListener('click', (e) => {
if (e.target instanceof HTMLImageElement && e.target.classList.contains('shot')) {
img.src = e.target.src;
box.showModal();
} else if (e.target === box) {
box.close();
}
});
const all = document.getElementById('all');
const documented = document.getElementById('documented');
function filter(onlyDocumented) {
all.setAttribute('aria-pressed', String(!onlyDocumented));
documented.setAttribute('aria-pressed', String(onlyDocumented));
for (const el of document.querySelectorAll('.scenario')) {
el.hidden = onlyDocumented && el.dataset.facts === '0';
}
for (const area of document.querySelectorAll('.area')) {
area.hidden = ![...area.querySelectorAll('.scenario')].some((s) => !s.hidden);
}
}
all.addEventListener('click', () => filter(false));
documented.addEventListener('click', () => filter(true));
document.getElementById('expand').addEventListener('click', () =>
document.querySelectorAll('.scenario').forEach((d) => (d.open = true)));
document.getElementById('collapse').addEventListener('click', () =>
document.querySelectorAll('.scenario').forEach((d) => (d.open = false)));
document.getElementById('pdf').addEventListener('click', () => window.print());
// Printing a collapsed prints only its summary, so open everything
// first and put it back the way the reader had it afterwards.
let restore = [];
window.addEventListener('beforeprint', () => {
// Open every details element (scenarios and the API/header blocks inside),
// since a collapsed one prints only its summary line.
restore = [...document.querySelectorAll('details')].map((d) => [d, d.open]);
for (const [d] of restore) {
d.open = true;
}
for (const img of document.querySelectorAll('img.shot')) {
img.loading = 'eager';
}
});
window.addEventListener('afterprint', () => {
for (const [d, was] of restore) {
d.open = was;
}
});`;