DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to generate a PDF test report from Cypress

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

  1. A Cypress plugin that captures a screenshot via PageBolt whenever a test fails
  2. A post-run script that reads the screenshot folder, builds an HTML summary, and POSTs it to PageBolt's PDF endpoint
  3. A GitHub Actions step that wires it all together

Setup

npm install --save-dev cypress mochawesome mochawesome-merge
Enter fullscreen mode Exit fullscreen mode

Add your PageBolt API key as an environment variable:

# .env (or GitHub Actions secret)
PAGEBOLT_API_KEY=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

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;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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} &nbsp;|&nbsp;
    Duration: ${(stats.duration / 1000).toFixed(1)}s &nbsp;|&nbsp;
    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);
Enter fullscreen mode Exit fullscreen mode

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;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)