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,126 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Exports the evidence report to PDF, headlessly.
|
||||
*
|
||||
* Uses the Chromium that Playwright already installed, so there is no new
|
||||
* dependency and no need to open a browser by hand. The browser's own
|
||||
* File > Print also works and produces the same layout; this exists so the
|
||||
* export can be scripted, and so CI can attach a PDF to a build.
|
||||
*
|
||||
* node scripts/report-pdf.mjs everything
|
||||
* node scripts/report-pdf.mjs --summary summary plus the narrated scenarios only
|
||||
* node scripts/report-pdf.mjs --out x.pdf write somewhere else
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const E2E = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REPORT_DIR = join(E2E, 'evidence-report');
|
||||
const SOURCE = join(REPORT_DIR, 'index.html');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const summaryOnly = args.includes('--summary');
|
||||
|
||||
const outFlag = args.indexOf('--out');
|
||||
const requested = outFlag !== -1 ? args[outFlag + 1] : undefined;
|
||||
const OUT = requested
|
||||
? isAbsolute(requested)
|
||||
? requested
|
||||
: resolve(process.cwd(), requested)
|
||||
: join(REPORT_DIR, summaryOnly ? 'TravelHub-test-report-summary.pdf' : 'TravelHub-test-report.pdf');
|
||||
|
||||
if (!existsSync(SOURCE)) {
|
||||
console.error(
|
||||
`No report found at ${SOURCE}\n` + 'Run the suite first: npm run test:e2e',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(pathToFileURL(SOURCE).href, { waitUntil: 'load' });
|
||||
|
||||
const counts = await page.evaluate(async (opts) => {
|
||||
if (opts.summaryOnly) {
|
||||
// Drop scenarios that carry only a screenshot, then any area left empty.
|
||||
for (const el of document.querySelectorAll('.scenario')) {
|
||||
if (el.dataset.facts === '0') {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
for (const area of document.querySelectorAll('.area')) {
|
||||
if (!area.querySelector('.scenario')) {
|
||||
area.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A collapsed <details> would print as a one-line summary.
|
||||
for (const details of document.querySelectorAll('details')) {
|
||||
details.open = true;
|
||||
}
|
||||
|
||||
// Lazy images never load off-screen, and the PDF renderer does not scroll.
|
||||
// Only the screenshots: the lightbox holds an empty <img> that never loads.
|
||||
const images = [...document.querySelectorAll('img.shot')];
|
||||
for (const img of images) {
|
||||
img.loading = 'eager';
|
||||
}
|
||||
await Promise.all(
|
||||
images.map((img) =>
|
||||
img.complete
|
||||
? null
|
||||
: new Promise((done) => {
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
scenarios: document.querySelectorAll('.scenario').length,
|
||||
images: images.length,
|
||||
unloaded: images.filter((i) => i.naturalWidth === 0).length,
|
||||
};
|
||||
}, { summaryOnly });
|
||||
|
||||
if (counts.unloaded > 0) {
|
||||
console.warn(` warning: ${counts.unloaded} screenshot(s) failed to load`);
|
||||
}
|
||||
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
|
||||
const foot =
|
||||
'<div style="width:100%;font-size:8pt;color:#9ca3af;padding:0 12mm;' +
|
||||
'font-family:Segoe UI,Helvetica,Arial,sans-serif;display:flex;justify-content:space-between">' +
|
||||
'<span>TravelHub — test execution report</span>' +
|
||||
'<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>' +
|
||||
'</div>';
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
|
||||
await page.pdf({
|
||||
path: OUT,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>', // suppress Chromium's default header
|
||||
footerTemplate: foot,
|
||||
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
|
||||
});
|
||||
|
||||
const mb = (statSync(OUT).size / 1024 / 1024).toFixed(1);
|
||||
console.log(
|
||||
`PDF written: ${OUT}\n` +
|
||||
` ${counts.scenarios} scenarios · ${counts.images} screenshots · ${mb} MB` +
|
||||
(summaryOnly ? ' (summary: narrated scenarios only)' : ''),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user