How to generate a PDF test report from Cypress
Cypress generates HTML test reports that only work if you have a browser open and know where to find the file. Stakeholders want PDFs — something they can open in email, print, archive, or drop into a ticket. The existing PDF plugins for Cypress either require a headless browser locally or are abandoned. There's an easier path.
After your Cypress run, you have an HTML report and a folder of screenshots. Here's how to compile them into a clean, shareable PDF using the PageBolt API — no Puppeteer install required.
What we're building
- A Cypress plugin that captures a screenshot via PageBolt whenever a test fails
- A post-run script that reads the screenshot folder, builds an HTML summary, and POSTs it to PageBolt's PDF endpoint
- A GitHub Actions step that wires it all together
Setup
npm install --save-dev cypress mochawesome mochawesome-merge
Add your PageBolt API key as an environment variable:
# .env (or GitHub Actions secret)
PAGEBOLT_API_KEY=YOUR_API_KEY
Configure Cypress to use the Mochawesome reporter in cypress.config.js:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
reporter: 'mochawesome',
reporterOptions: {
reportDir: 'cypress/reports',
overwrite: false,
html: true,
json: true,
},
e2e: {
setupNodeEvents(on, config) {
require('./cypress/plugins/pagebolt-screenshots')(on, config);
return config;
},
},
});
Plugin: capture failure screenshots via PageBolt
Instead of relying on Cypress's built-in screenshot mechanism (which takes a screenshot of your local browser, including any system chrome), this plugin captures a clean, hosted screenshot of the failing page URL via PageBolt.
// cypress/plugins/pagebolt-screenshots.js
const fs = require('fs');
const path = require('path');
async function capturePageBoltScreenshot(url, outputPath) {
const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
width: 1280,
height: 720,
format: 'png',
fullPage: true,
blockBanners: true,
}),
});
if (!res.ok) {
console.error('PageBolt screenshot failed:', await res.json());
return;
}
const buffer = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, buffer);
console.log(`Screenshot saved: ${outputPath}`);
}
module.exports = function (on, config) {
on('task', {
async screenshotFailingPage({ testTitle, pageUrl }) {
const safeName = testTitle.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const outputPath = path.join(
'cypress', 'screenshots', 'failures',
`${safeName}.png`
);
await capturePageBoltScreenshot(pageUrl, outputPath);
return outputPath;
},
});
};
Call it from your tests when you want to capture the current page on failure:
// cypress/support/commands.js
Cypress.Commands.add('screenshotOnFailure', () => {
cy.url().then((url) => {
cy.task('screenshotFailingPage', {
testTitle: Cypress.currentTest.title,
pageUrl: url,
});
});
});
// In your test:
afterEach(function () {
if (this.currentTest.state === 'failed') {
cy.screenshotOnFailure();
}
});
Post-run script: build the PDF report
After Cypress finishes, this script reads the Mochawesome JSON results, builds an HTML summary page, and converts it to a PDF via PageBolt.
// scripts/generate-pdf-report.js
const fs = require('fs');
const path = require('path');
async function generatePdfReport() {
// Read Mochawesome JSON results
const reportsDir = path.join('cypress', 'reports');
const jsonFiles = fs.readdirSync(reportsDir)
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(path.join(reportsDir, f), 'utf8')));
if (jsonFiles.length === 0) {
console.error('No Mochawesome reports found in cypress/reports/');
process.exit(1);
}
const report = jsonFiles[0]; // or merge multiple with mochawesome-merge
const { stats, results } = report;
// Read failure screenshots
const screenshotsDir = path.join('cypress', 'screenshots', 'failures');
const screenshots = fs.existsSync(screenshotsDir)
? fs.readdirSync(screenshotsDir).filter(f => f.endsWith('.png'))
: [];
// Build the HTML summary
const screenshotHtml = screenshots.map(file => {
const imgData = fs.readFileSync(path.join(screenshotsDir, file));
const base64 = imgData.toString('base64');
const testName = file.replace('.png', '').replace(/-/g, ' ');
return `
<div class="screenshot-block">
<h3>Failure: ${testName}</h3>
<img src="data:image/png;base64,${base64}" style="max-width:100%;border:1px solid #e2e8f0;border-radius:6px;" />
</div>`;
}).join('\n');
const passRate = stats.passes / (stats.passes + stats.failures) * 100;
const statusColor = stats.failures === 0 ? '#22c55e' : '#ef4444';
const runDate = new Date().toLocaleString();
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 40px; color: #1e293b; }
h1 { font-size: 28px; margin-bottom: 4px; }
.meta { color: #64748b; font-size: 14px; margin-bottom: 32px; }
.stats { display: flex; gap: 24px; margin-bottom: 40px; flex-wrap: wrap; }
.stat-card { padding: 20px 28px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; min-width: 140px; }
.stat-card .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.stat-card .value { font-size: 36px; font-weight: 700; margin-top: 4px; }
.pass { color: #22c55e; } .fail { color: #ef4444; } .skip { color: #f59e0b; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 9999px; color: white; font-weight: 600; background: ${statusColor}; }
.screenshot-block { margin: 32px 0; page-break-inside: avoid; }
.screenshot-block h3 { font-size: 16px; margin-bottom: 12px; color: #ef4444; }
h2 { font-size: 20px; margin-top: 40px; border-bottom: 2px solid #e2e8f0; padding-bottom: 8px; }
</style>
</head>
<body>
<h1>Cypress Test Report</h1>
<div class="meta">
Run date: ${runDate} |
Duration: ${(stats.duration / 1000).toFixed(1)}s |
Status: <span class="status-badge">${stats.failures === 0 ? 'PASSED' : 'FAILED'}</span>
</div>
<div class="stats">
<div class="stat-card"><div class="label">Total</div><div class="value">${stats.tests}</div></div>
<div class="stat-card"><div class="label">Passed</div><div class="value pass">${stats.passes}</div></div>
<div class="stat-card"><div class="label">Failed</div><div class="value fail">${stats.failures}</div></div>
<div class="stat-card"><div class="label">Skipped</div><div class="value skip">${stats.pending}</div></div>
<div class="stat-card"><div class="label">Pass Rate</div><div class="value">${passRate.toFixed(0)}%</div></div>
</div>
${screenshots.length > 0 ? `<h2>Failure Screenshots</h2>${screenshotHtml}` : '<p style="color:#22c55e;font-weight:600;">All tests passed — no failure screenshots.</p>'}
</body>
</html>`;
// POST to PageBolt PDF endpoint
const res = await fetch('https://pagebolt.dev/api/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
format: 'A4',
margin: '1.5cm',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `<div style="font-size:10px;color:#94a3b8;text-align:right;width:100%;padding-right:1.5cm;">Cypress Test Report — ${runDate}</div>`,
footerTemplate: `<div style="font-size:10px;color:#94a3b8;text-align:center;width:100%;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
}),
});
if (!res.ok) {
const err = await res.json();
console.error('PageBolt PDF error:', err);
process.exit(1);
}
const outputPath = path.join('cypress', 'reports', 'test-report.pdf');
const pdfBuffer = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(outputPath, pdfBuffer);
console.log(`PDF report saved: ${outputPath}`);
}
generatePdfReport().catch(console.error);
The after:run hook
You can trigger the PDF generation automatically at the end of every Cypress run by adding an after:run hook to your config:
// cypress.config.js (updated)
const { defineConfig } = require('cypress');
const { execSync } = require('child_process');
module.exports = defineConfig({
reporter: 'mochawesome',
reporterOptions: {
reportDir: 'cypress/reports',
overwrite: false,
html: true,
json: true,
},
e2e: {
setupNodeEvents(on, config) {
require('./cypress/plugins/pagebolt-screenshots')(on, config);
on('after:run', async (results) => {
console.log('Generating PDF report...');
execSync('node scripts/generate-pdf-report.js', { stdio: 'inherit' });
});
return config;
},
},
});
GitHub Actions integration
# .github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main]
pull_request:
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run Cypress tests
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
run: npx cypress run
continue-on-error: true # collect results even if tests fail
- name: Generate PDF report
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
run: node scripts/generate-pdf-report.js
- name: Upload PDF report
uses: actions/upload-artifact@v4
with:
name: cypress-pdf-report
path: cypress/reports/test-report.pdf
The PDF is uploaded as a GitHub Actions artifact on every run. Your PM can download it directly from the Actions tab without needing Node.js or a browser — just a PDF viewer.
Use cases for this pattern
QA sign-off: Share a PDF with the QA team or client before every release. They see pass/fail stats and screenshots of anything that broke — no access to your CI system required.
Compliance evidence: Some regulated industries require documented test results. A dated PDF with screenshots is archivable evidence that tests ran and what the state of the application was.
Test history: Store PDFs in S3 or Google Drive per release tag. Build a simple index page and you have a searchable history of every test run, what failed, and what it looked like at the time.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)