Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Builds a run report from the evidence captured by passing tests.
|
||||
*
|
||||
* Playwright's own HTML report is a debugging tool: it shows what went wrong.
|
||||
* This one is the opposite -- a record of what the suite proved, with the
|
||||
* screenshots and the data each scenario observed, readable by somebody who
|
||||
* will never open a terminal.
|
||||
*
|
||||
* Output lands in `e2e/evidence-report/`:
|
||||
* index.html the report
|
||||
* report.json the same content, machine readable
|
||||
* report.md a text summary for pasting into a ticket or a PR
|
||||
* shots/ the screenshots
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
||||
|
||||
import { DATA_PREFIX, SHOT_PREFIX } from '../fixtures/evidence';
|
||||
import { renderHtml } from './render-html';
|
||||
import {
|
||||
RunMeta,
|
||||
Scenario,
|
||||
Step,
|
||||
Summary,
|
||||
areaFor,
|
||||
formatValue,
|
||||
friendly,
|
||||
group,
|
||||
label,
|
||||
sentenceCase,
|
||||
} from './report-model';
|
||||
|
||||
const OUT_DIR = resolve(__dirname, '..', 'evidence-report');
|
||||
const SHOT_DIR = join(OUT_DIR, 'shots');
|
||||
|
||||
export default class EvidenceReporter implements Reporter {
|
||||
private readonly scenarios: Scenario[] = [];
|
||||
private startedAt = Date.now();
|
||||
private rootDir = '';
|
||||
private meta: RunMeta = { baseURL: 'unknown', projects: [], workers: 1 };
|
||||
|
||||
onBegin(config: FullConfig, _suite: Suite): void {
|
||||
this.startedAt = Date.now();
|
||||
this.rootDir = config.rootDir;
|
||||
|
||||
this.meta = {
|
||||
baseURL: String(config.projects[0]?.use?.baseURL ?? 'unknown'),
|
||||
projects: config.projects.map((project) => {
|
||||
const viewport = project.use?.viewport;
|
||||
const size = viewport ? `${viewport.width} x ${viewport.height}` : 'default size';
|
||||
return `${project.name} (${size})`;
|
||||
}),
|
||||
workers: config.workers,
|
||||
};
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true });
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult): void {
|
||||
const path = test.titlePath().filter(Boolean);
|
||||
// titlePath is [project, file, ...describes, title]
|
||||
const project = path[0] ?? 'default';
|
||||
const file = relative(this.rootDir, test.location.file).split(sep).join('/');
|
||||
const title = path[path.length - 1] ?? test.title;
|
||||
const suite = path.slice(2, -1).join(' › ');
|
||||
|
||||
const steps = this.collectSteps(test, result);
|
||||
|
||||
this.scenarios.push({
|
||||
project,
|
||||
file,
|
||||
suite,
|
||||
title,
|
||||
status: result.status,
|
||||
expected: result.status === test.expectedStatus,
|
||||
durationMs: result.duration,
|
||||
steps,
|
||||
error: result.error?.message?.split('\n').slice(0, 4).join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pairs `evidence:NN:name` screenshots with `evidence-data:NN:name` blobs. */
|
||||
private collectSteps(test: TestCase, result: TestResult): Step[] {
|
||||
const bySeq = new Map<string, Step>();
|
||||
|
||||
for (const attachment of result.attachments) {
|
||||
const match = /^(evidence|evidence-data):(\d+):(.*)$/.exec(attachment.name);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, kind, seq, name] = match;
|
||||
|
||||
let step = bySeq.get(seq);
|
||||
if (!step) {
|
||||
step = { seq, name };
|
||||
bySeq.set(seq, step);
|
||||
}
|
||||
|
||||
if (kind === SHOT_PREFIX) {
|
||||
// An attachment made with `body` carries the bytes in memory; one made
|
||||
// with `path` points at a file on disk. Both shapes occur.
|
||||
const safe = `${slug(test.id)}-${seq}.png`;
|
||||
try {
|
||||
if (attachment.body) {
|
||||
writeFileSync(join(SHOT_DIR, safe), attachment.body);
|
||||
step.shot = `shots/${safe}`;
|
||||
} else if (attachment.path) {
|
||||
copyFileSync(attachment.path, join(SHOT_DIR, safe));
|
||||
step.shot = `shots/${safe}`;
|
||||
}
|
||||
} catch {
|
||||
// A screenshot that could not be saved should not sink the report.
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === DATA_PREFIX) {
|
||||
const raw = attachment.body
|
||||
? attachment.body.toString('utf8')
|
||||
: attachment.path
|
||||
? safeRead(attachment.path)
|
||||
: '';
|
||||
if (raw) {
|
||||
try {
|
||||
step.facts = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
step.facts = { raw };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const steps = [...bySeq.values()].sort((a, b) => a.seq.localeCompare(b.seq));
|
||||
if (steps.length > 0) {
|
||||
return steps;
|
||||
}
|
||||
|
||||
// Not every test is instrumented. Fall back to Playwright's own
|
||||
// end-of-test screenshot so the report still shows what the run looked
|
||||
// like, just without the narrated data.
|
||||
const auto = result.attachments.find(
|
||||
(a) => a.name === 'screenshot' && a.contentType === 'image/png',
|
||||
);
|
||||
if (!auto) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safe = `${slug(test.id)}-final.png`;
|
||||
try {
|
||||
if (auto.body) {
|
||||
writeFileSync(join(SHOT_DIR, safe), auto.body);
|
||||
} else if (auto.path) {
|
||||
copyFileSync(auto.path, join(SHOT_DIR, safe));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ seq: '01', name: 'Final state', shot: `shots/${safe}` }];
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult): Promise<void> {
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
outcome: result.status,
|
||||
durationMs,
|
||||
total: this.scenarios.length,
|
||||
passed: this.scenarios.filter((s) => s.status === 'passed').length,
|
||||
failed: this.scenarios.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
|
||||
skipped: this.scenarios.filter((s) => s.status === 'skipped').length,
|
||||
narrated: this.scenarios.filter((s) => s.steps.some((step) => step.facts)).length,
|
||||
screenshots: this.scenarios.reduce(
|
||||
(n, s) => n + s.steps.filter((step) => step.shot).length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(OUT_DIR, 'report.json'),
|
||||
JSON.stringify({ summary, scenarios: this.scenarios }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(join(OUT_DIR, 'report.md'), renderMarkdown(summary, this.scenarios), 'utf8');
|
||||
writeFileSync(
|
||||
join(OUT_DIR, 'index.html'),
|
||||
renderHtml(summary, this.scenarios, this.meta),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// An absolute file:// URL, because a relative path is ambiguous: the suite
|
||||
// runs with its cwd in e2e/, but people launch it from the repo root.
|
||||
const file = join(OUT_DIR, 'index.html');
|
||||
const url = pathToFileURL(file).href;
|
||||
|
||||
console.log(
|
||||
[
|
||||
'',
|
||||
'Evidence report — what passed, with data and screenshots:',
|
||||
` ${url}`,
|
||||
` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` +
|
||||
`${summary.narrated} scenarios with recorded data`,
|
||||
'',
|
||||
' Open it: npm run report',
|
||||
' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href,
|
||||
'',
|
||||
" (`npx playwright show-report` opens Playwright's own report, which is a",
|
||||
' different artifact — it is for debugging failures.)',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 60);
|
||||
}
|
||||
|
||||
function safeRead(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// --- markdown --------------------------------------------------------------
|
||||
|
||||
function renderMarkdown(summary: Summary, scenarios: Scenario[]): string {
|
||||
const lines: string[] = [
|
||||
'# TravelHub end-to-end run report',
|
||||
'',
|
||||
`Generated ${summary.generatedAt} · ${summary.passed}/${summary.total} passed · ` +
|
||||
`${friendly(summary.durationMs)}`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const file of group(scenarios)) {
|
||||
lines.push(`## ${areaFor(file.name).title}`, '', `_${areaFor(file.name).blurb}_`, '');
|
||||
for (const scenario of file.items) {
|
||||
const mark = scenario.status === 'passed' ? '✅' : scenario.status === 'skipped' ? '⏭️' : '❌';
|
||||
const heading = sentenceCase(scenario.title);
|
||||
lines.push(`### ${mark} ${heading}`, '');
|
||||
lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, '');
|
||||
|
||||
if (scenario.steps.length === 0) {
|
||||
lines.push('_No evidence captured._', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const step of scenario.steps) {
|
||||
lines.push(`**${step.seq}. ${step.name}**`, '');
|
||||
if (step.facts) {
|
||||
lines.push('| Field | Value |', '| ----- | ----- |');
|
||||
for (const [key, value] of Object.entries(step.facts)) {
|
||||
lines.push(`| ${label(key)} | ${formatValue(value)} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (step.shot) {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* 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 {
|
||||
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) =>
|
||||
`<li><a href="#area-${a.index}"><span class="num">2.${a.index}</span>${escapeHtml(
|
||||
a.area.title,
|
||||
)}</a><span class="dots"></span><span class="count">${a.items.length} tests</span></li>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const summaryRows = areas
|
||||
.map((a) => {
|
||||
const rate = Math.round((a.passed / Math.max(a.items.length, 1)) * 100);
|
||||
return `
|
||||
<tr>
|
||||
<td class="num-cell">2.${a.index}</td>
|
||||
<td><a href="#area-${a.index}">${escapeHtml(a.area.title)}</a></td>
|
||||
<td class="figure">${a.items.length}</td>
|
||||
<td class="figure">${a.passed}</td>
|
||||
<td class="figure">${a.failed || '—'}</td>
|
||||
<td>
|
||||
<div class="bar" title="${rate}% passed"><span style="width:${rate}%"></span></div>
|
||||
</td>
|
||||
<td><span class="tag ${a.failed ? 'fail' : 'pass'}">${a.failed ? 'Failed' : 'Passed'}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>TravelHub — Test Execution Report</title>
|
||||
<style>
|
||||
${STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
|
||||
<div class="cover">
|
||||
<div class="eyebrow">Test execution report</div>
|
||||
<h1>TravelHub</h1>
|
||||
<p class="when">Multi-tenant flight booking · end-to-end suite<br />${escapeHtml(stamp)}</p>
|
||||
|
||||
<div class="verdict ${allPassed ? 'ok' : 'bad'}">
|
||||
<span class="mark">${allPassed ? '✓' : '!'}</span>
|
||||
<span>
|
||||
<span class="headline">${
|
||||
allPassed ? 'All tests passed' : `${summary.failed} of ${summary.total} tests failed`
|
||||
}</span><br />
|
||||
<span class="sub">${summary.passed} of ${summary.total} scenarios verified in ${friendly(
|
||||
summary.durationMs,
|
||||
)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="facts-grid">
|
||||
<div><dt>Application under test</dt><dd>${escapeHtml(meta.baseURL)}</dd></div>
|
||||
<div><dt>Browsers</dt><dd>${escapeHtml(meta.projects.join(', ') || 'chromium')}</dd></div>
|
||||
<div><dt>Scenarios executed</dt><dd>${summary.total}</dd></div>
|
||||
<div><dt>Duration</dt><dd>${friendly(summary.durationMs)}</dd></div>
|
||||
<div><dt>Screenshots captured</dt><dd>${summary.screenshots}</dd></div>
|
||||
<div><dt>Scenarios with recorded data</dt><dd>${summary.narrated}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-title"><span class="num">1</span><h2>Summary</h2></div>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<table class="summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>Area tested</th><th class="figure">Tests</th>
|
||||
<th class="figure">Passed</th><th class="figure">Failed</th>
|
||||
<th>Pass rate</th><th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${summaryRows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td><td>Total</td>
|
||||
<td class="figure">${summary.total}</td>
|
||||
<td class="figure">${summary.passed}</td>
|
||||
<td class="figure">${summary.failed || '—'}</td>
|
||||
<td></td>
|
||||
<td><span class="tag ${allPassed ? 'pass' : 'fail'}">${allPassed ? 'Passed' : 'Failed'}</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3 class="sub-head">Contents</h3>
|
||||
<ol class="toc">${contents}</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title"><span class="num">2</span><h2>Detailed results</h2></div>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button id="all" aria-pressed="true">All scenarios</button>
|
||||
<button id="documented" aria-pressed="false">With recorded data only</button>
|
||||
<button id="expand">Expand all</button>
|
||||
<button id="collapse">Collapse all</button>
|
||||
<button id="pdf" title="Opens your browser's print dialog; choose "Save as PDF"">Save as PDF</button>
|
||||
<span class="legend">
|
||||
<span><i class="swatch pass"></i>Passed</span>
|
||||
<span><i class="swatch fail"></i>Failed</span>
|
||||
<span><i class="swatch skip"></i>Not run</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${areas.map(renderArea).join('')}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Generated automatically by the TravelHub end-to-end suite
|
||||
(<code>npm run test:e2e</code>) and reopened with <code>npm run report</code>.
|
||||
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.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog id="lightbox"><img alt="" /></dialog>
|
||||
|
||||
<script>
|
||||
${SCRIPT}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderArea(entry: AreaBlock): string {
|
||||
const scenarios = entry.items
|
||||
.map((scenario, i) => renderScenario(scenario, `2.${entry.index}.${i + 1}`))
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="area" id="area-${entry.index}">
|
||||
<div class="area-head">
|
||||
<div class="section-title">
|
||||
<span class="num">2.${entry.index}</span>
|
||||
<h2 class="area-title">${escapeHtml(entry.area.title)}</h2>
|
||||
<span class="tag ${entry.failed ? 'fail' : 'pass'}">${entry.passed}/${entry.items.length} passed</span>
|
||||
</div>
|
||||
<p class="lede area-blurb">${escapeHtml(entry.area.blurb)}</p>
|
||||
</div>
|
||||
${scenarios}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderScenario(scenario: Scenario, ref: string): string {
|
||||
const hasFacts = scenario.steps.some((s) => s.facts);
|
||||
const steps =
|
||||
scenario.steps.length === 0
|
||||
? '<p class="no-evidence">No screenshot was captured for this scenario.</p>'
|
||||
: scenario.steps.map(renderStep).join('');
|
||||
|
||||
return `
|
||||
<details class="scenario" data-facts="${hasFacts ? '1' : '0'}"${hasFacts ? ' open' : ''}>
|
||||
<summary>
|
||||
<span class="ref">${ref}</span>
|
||||
<span class="headline">
|
||||
${scenario.suite ? `<span class="group">${escapeHtml(scenario.suite)}</span>` : ''}
|
||||
<span class="what">${escapeHtml(sentenceCase(scenario.title))}</span>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span class="hide-narrow">${escapeHtml(scenario.project)}</span>
|
||||
<span>${friendly(scenario.durationMs)}</span>
|
||||
<span class="tag ${statusClass(scenario.status)}">${statusWord(scenario.status)}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">
|
||||
${scenario.error ? `<pre class="error">${escapeHtml(scenario.error)}</pre>` : ''}
|
||||
${steps}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderStep(step: Step): string {
|
||||
const facts = step.facts
|
||||
? `<table class="facts">${Object.entries(step.facts)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<tr><td>${escapeHtml(label(key))}</td><td>${escapeHtml(formatValue(value))}</td></tr>`,
|
||||
)
|
||||
.join('')}</table>`
|
||||
: '';
|
||||
|
||||
const shot = step.shot
|
||||
? `<figure>
|
||||
<img class="shot" loading="lazy" src="${escapeHtml(step.shot)}" alt="${escapeHtml(step.name)}" />
|
||||
<figcaption>Screen at this point<span class="hint"> · click to enlarge</span></figcaption>
|
||||
</figure>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="step">
|
||||
<h4><span class="n">${escapeHtml(step.seq)}</span>${escapeHtml(step.name)}</h4>
|
||||
${facts}
|
||||
${shot}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
.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 <details> 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 <details> prints only its summary, so open everything
|
||||
// first and put it back the way the reader had it afterwards.
|
||||
let restore = [];
|
||||
window.addEventListener('beforeprint', () => {
|
||||
restore = [...document.querySelectorAll('.scenario')].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;
|
||||
}
|
||||
});`;
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The shape of a run report, and the vocabulary it is written in.
|
||||
*
|
||||
* Kept separate from both the collector and the renderers so neither depends on
|
||||
* the other: `evidence.ts` gathers, `render-html.ts` presents, and both agree
|
||||
* on what a scenario is by importing from here.
|
||||
*/
|
||||
import type { TestResult } from '@playwright/test/reporter';
|
||||
|
||||
/** One captured moment inside a scenario. */
|
||||
export interface Step {
|
||||
seq: string;
|
||||
name: string;
|
||||
shot?: string; // path relative to the report root
|
||||
facts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One test, as the report talks about it. */
|
||||
export interface Scenario {
|
||||
project: string;
|
||||
file: string;
|
||||
suite: string;
|
||||
title: string;
|
||||
status: TestResult['status'];
|
||||
expected: boolean;
|
||||
durationMs: number;
|
||||
steps: Step[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** How the run was configured, for the cover page. */
|
||||
export interface RunMeta {
|
||||
baseURL: string;
|
||||
projects: string[];
|
||||
workers: number;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
generatedAt: string;
|
||||
outcome: string;
|
||||
durationMs: number;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
narrated: number;
|
||||
screenshots: number;
|
||||
}
|
||||
|
||||
// --- areas -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Spec filenames are an implementation detail. The report groups by what the
|
||||
* tests actually cover, so somebody who has never opened the codebase can read
|
||||
* it without a glossary.
|
||||
*/
|
||||
export interface Area {
|
||||
title: string;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const AREAS: Record<string, Area> = {
|
||||
'auth.spec.ts': {
|
||||
title: 'Signing in and sessions',
|
||||
blurb:
|
||||
'Agents sign in against their own agency. Credentials issued by one agency must not work at another, and a session should survive a page reload but not outlive a sign-out.',
|
||||
},
|
||||
'search.spec.ts': {
|
||||
title: 'Searching for flights',
|
||||
blurb:
|
||||
'Filtering the agency inventory by route, date, cabin and party size; ordering results by fare, journey time or departure; and paging through them.',
|
||||
},
|
||||
'booking.spec.ts': {
|
||||
title: 'Booking and cancelling',
|
||||
blurb:
|
||||
'Selling a ticket end to end: the fare carried into checkout, traveller details, confirmation with a booking reference, and cancellation returning the seats to inventory.',
|
||||
},
|
||||
'tenant-isolation.spec.ts': {
|
||||
title: 'Keeping agencies separate',
|
||||
blurb:
|
||||
'The central promise of the platform. Two agencies share one deployment and one database, and neither may see the other’s inventory, customers or revenue — not even by guessing a web address.',
|
||||
},
|
||||
'dashboard.spec.ts': {
|
||||
title: 'Agency dashboard',
|
||||
blurb:
|
||||
'Trading figures for the signed-in agency — revenue, seats sold, bookings and best-selling routes — and that they move correctly when a sale is made and when it is cancelled.',
|
||||
},
|
||||
'simulation.spec.ts': {
|
||||
title: 'Behaviour when things go wrong',
|
||||
blurb:
|
||||
'Here the backend is simulated so it can misbehave on demand: server errors, slow responses, a dropped network, an expired session, a seat sold mid-purchase, and awkward data such as very long names.',
|
||||
},
|
||||
'responsive.spec.ts': {
|
||||
title: 'Phones and small screens',
|
||||
blurb:
|
||||
'The console is used at a desk and on a phone. No screen may scroll sideways at phone width, and wide tables must scroll inside their own frame rather than stretching the page.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Reading order, rather than alphabetical: a reader follows the user journey. */
|
||||
export const AREA_ORDER = [
|
||||
'auth.spec.ts',
|
||||
'search.spec.ts',
|
||||
'booking.spec.ts',
|
||||
'tenant-isolation.spec.ts',
|
||||
'dashboard.spec.ts',
|
||||
'simulation.spec.ts',
|
||||
'responsive.spec.ts',
|
||||
];
|
||||
|
||||
export function areaFor(file: string): Area {
|
||||
const name = file.split('/').pop() ?? file;
|
||||
return AREAS[name] ?? { title: name, blurb: '' };
|
||||
}
|
||||
|
||||
export function group(scenarios: Scenario[]): Array<{ name: string; items: Scenario[] }> {
|
||||
const map = new Map<string, Scenario[]>();
|
||||
for (const scenario of scenarios) {
|
||||
const list = map.get(scenario.file) ?? [];
|
||||
list.push(scenario);
|
||||
map.set(scenario.file, list);
|
||||
}
|
||||
|
||||
const rank = (file: string) => {
|
||||
const index = AREA_ORDER.indexOf(file.split('/').pop() ?? file);
|
||||
return index === -1 ? AREA_ORDER.length : index;
|
||||
};
|
||||
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([name, items]) => ({ name, items }));
|
||||
}
|
||||
|
||||
// --- formatting ------------------------------------------------------------
|
||||
|
||||
export function escapeHtml(value: unknown): string {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** "1 min 30 s" reads better in a summary than "89.8 s". */
|
||||
export function friendly(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`;
|
||||
}
|
||||
const total = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(total / 60);
|
||||
const secs = total % 60;
|
||||
return minutes === 0 ? `${secs} s` : `${minutes} min ${String(secs).padStart(2, '0')} s`;
|
||||
}
|
||||
|
||||
export function sentenceCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
/** seatsAvailable -> "Seats available", so data tables read as English. */
|
||||
export function label(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return sentenceCase(spaced);
|
||||
}
|
||||
|
||||
export function statusWord(status: Scenario['status']): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'Passed';
|
||||
case 'skipped':
|
||||
return 'Not run';
|
||||
case 'timedOut':
|
||||
return 'Timed out';
|
||||
default:
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusClass(status: Scenario['status']): string {
|
||||
if (status === 'passed') {
|
||||
return 'pass';
|
||||
}
|
||||
return status === 'skipped' ? 'skip' : 'fail';
|
||||
}
|
||||
Reference in New Issue
Block a user