/** * 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.

    ${summaryRows}
    Area testedTests PassedFailed Pass rateResult
    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.

    Passed Failed Not run
    ${areas.map(renderArea).join('')}
    `; } 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 headers${headerRows}
    ${request} ${response}
    `; } function renderStep(step: Step): string { const facts = step.facts ? `${Object.entries(step.facts) .map( ([key, value]) => ``, ) .join('')}
    ${escapeHtml(label(key))}${escapeHtml(formatValue(value))}
    ` : ''; const shot = step.shot ? `
    ${escapeHtml(step.name)}
    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; } });`;