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>
127 lines
4.2 KiB
JavaScript
127 lines
4.2 KiB
JavaScript
#!/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();
|
|
}
|