Repository navigation
PPLT-6073: native PDF visual testing via POST /percy/pdf/snapshot #2418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
974049d
0839e27
8cdf248
3253cc0
1cd1763
45ceaf2
858d617
12cce15
99f29a8
1ad51f7
d23df96
35ea1eb
1e61fd4
750a5f0
e6b4108
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| { | ||
| "name": "@percy/cli-pdf", | ||
| "version": "1.32.10-beta.0", | ||
| "license": "MIT", | ||
| "description": "Renders PDF documents into per-page images for Percy snapshots", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/percy/cli", | ||
| "directory": "packages/cli-pdf" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "tag": "beta" | ||
| }, | ||
| "engines": { | ||
| "node": ">=14" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "main": "./dist/index.js", | ||
| "type": "module", | ||
| "exports": "./dist/index.js", | ||
| "scripts": { | ||
| "build": "node ../../scripts/build", | ||
| "lint": "eslint --ignore-path ../../.gitignore .", | ||
| "test": "node ../../scripts/test", | ||
| "test:coverage": "yarn test --coverage" | ||
| }, | ||
| "dependencies": { | ||
| "@percy/logger": "1.32.10-beta.0", | ||
| "pdfjs-dist": "^2.16.105" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import path from 'path'; | ||
| import { createRequire } from 'module'; | ||
|
|
||
| const cjsRequire = createRequire(import.meta.url); | ||
|
|
||
| export function pdfjsAssets() { | ||
| let root = path.dirname(cjsRequire.resolve('pdfjs-dist/package.json')); | ||
|
|
||
| return { | ||
| root, | ||
| buildDir: path.join(root, 'legacy/build'), | ||
| standardFontsDir: path.join(root, 'standard_fonts'), | ||
| cmapsDir: path.join(root, 'cmaps'), | ||
| libPath: path.join(root, 'legacy/build/pdf.js'), | ||
| workerFile: 'pdf.worker.js' | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| export const MIN_DIMENSION = 10; | ||
| export const MAX_DIMENSION = 2000; | ||
|
|
||
| export const DEFAULT_SCALE = 2; | ||
| export const MAX_SCALE = 5; | ||
|
|
||
| // Wall-clock ceiling for a single page's in-page work (open, measure, render). | ||
| // Page#eval resolves off `Runtime.callFunctionOn` with `awaitPromise: true`, | ||
| // which has no timeout of its own -- Page.TIMEOUT only covers navigation. A PDF | ||
| // that wedges pdf.js would otherwise hang the HTTP request forever while | ||
| // holding a browser page and a listening asset server. | ||
| export const PAGE_RENDER_TIMEOUT = 30000; | ||
|
|
||
| export function fitScale(requestedScale, { width, height }) { | ||
| return Math.min(requestedScale, MAX_DIMENSION / width, MAX_DIMENSION / height); | ||
| } | ||
|
|
||
| export function assertRasterDimensions(pageNumber, width, height) { | ||
| if (width < MIN_DIMENSION || height < MIN_DIMENSION) { | ||
| throw new Error( | ||
| `Page ${pageNumber} rasterized to ${width}x${height}px, below Percy's ` + | ||
| `${MIN_DIMENSION}px minimum. Increase \`scale\`.` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export async function openDocument(_, { origin }) { | ||
| let lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib; | ||
|
|
||
| if (!lib) { | ||
| throw new Error('pdf.js did not initialise in the page'); | ||
| } | ||
|
|
||
| lib.GlobalWorkerOptions.workerSrc = `${origin}/pdfjs/pdf.worker.js`; | ||
|
|
||
| let doc = await lib.getDocument({ | ||
| url: `${origin}/doc.pdf`, | ||
| isEvalSupported: false, | ||
| standardFontDataUrl: `${origin}/standard_fonts/`, | ||
| cMapUrl: `${origin}/cmaps/`, | ||
| cMapPacked: true | ||
| }).promise; | ||
|
|
||
| window.__percyPdf = { lib, doc }; | ||
|
|
||
| return { pageCount: doc.numPages }; | ||
| } | ||
|
|
||
| export async function measurePages(_, { pageNumbers }) { | ||
| let { doc } = window.__percyPdf; | ||
| let sizes = []; | ||
|
|
||
| for (let pageNumber of pageNumbers) { | ||
| let page = await doc.getPage(pageNumber); | ||
| let { width, height } = page.getViewport({ scale: 1 }); | ||
| sizes.push({ pageNumber, width, height }); | ||
| page.cleanup(); | ||
| } | ||
|
|
||
| return sizes; | ||
| } | ||
|
|
||
| export async function renderPage(_, { pageNumber, scale }) { | ||
| let { doc } = window.__percyPdf; | ||
| let page = await doc.getPage(pageNumber); | ||
|
|
||
| try { | ||
| let viewport = page.getViewport({ scale }); | ||
| let width = Math.ceil(viewport.width); | ||
| let height = Math.ceil(viewport.height); | ||
|
|
||
| let canvas = document.createElement('canvas'); | ||
| canvas.width = width; | ||
| canvas.height = height; | ||
|
|
||
| let context = canvas.getContext('2d'); | ||
| context.fillStyle = '#ffffff'; | ||
| context.fillRect(0, 0, width, height); | ||
|
|
||
| await page.render({ canvasContext: context, viewport }).promise; | ||
|
|
||
| return { | ||
| width, | ||
| height, | ||
| dataUrl: canvas.toDataURL('image/png') | ||
| }; | ||
| } finally { | ||
| page.cleanup(); | ||
| } | ||
| } | ||
|
|
||
| export async function destroyDocument() { | ||
| let state = window.__percyPdf; | ||
|
|
||
| if (state?.doc) { | ||
| await state.doc.destroy(); | ||
| delete window.__percyPdf; | ||
| } | ||
|
|
||
| return true; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| export { pdfjsAssets } from './assets.js'; | ||
| export { resolvePages, MAX_PAGES } from './pages.js'; | ||
| export { | ||
| openDocument, | ||
| measurePages, | ||
| renderPage, | ||
| destroyDocument, | ||
| fitScale, | ||
| assertRasterDimensions, | ||
| DEFAULT_SCALE, | ||
| MAX_SCALE, | ||
| PAGE_RENDER_TIMEOUT, | ||
| MIN_DIMENSION, | ||
| MAX_DIMENSION | ||
| } from './browser-scripts.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // Ceiling on how many pages one request may rasterize. Every page is held in | ||
| // memory as a PNG twice over -- once in the rasterizer's result array, once in | ||
| // the resource closure the snapshot queue keeps -- and each additionally crosses | ||
| // CDP as a base64 data URL. A 50MB PDF can carry thousands of pages, so without | ||
| // a cap a single request can exhaust the heap. Callers who genuinely want more | ||
| // can narrow with `pages` and issue several requests. | ||
| export const MAX_PAGES = 250; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should come from BE, else you will need to do release everytime. You can get it as part of build creation
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe send it as follow up but create a ticket and link to epic
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| function parseSelection(value, pageCount) { | ||
| if (value == null) return range(1, pageCount); | ||
| if (typeof value === 'number') return [toPageNumber(value)]; | ||
| if (Array.isArray(value)) return value.map(toPageNumber); | ||
|
|
||
| if (typeof value !== 'string') { | ||
| throw new Error(`Invalid page selection: expected a number, array or string, got ${typeof value}`); | ||
| } | ||
|
|
||
| let selected = []; | ||
|
|
||
| for (let part of value.split(',')) { | ||
| part = part.trim(); | ||
| if (!part) continue; | ||
|
|
||
| let match = /^(\d+)\s*-\s*(\d+)?$/.exec(part); | ||
|
|
||
| if (match) { | ||
| let from = toPageNumber(match[1]); | ||
| let to = match[2] == null ? pageCount : toPageNumber(match[2]); | ||
| if (to < from) throw new Error(`Invalid page range "${part}": end page is before start page`); | ||
| selected.push(...range(from, to)); | ||
| } else if (/^\d+$/.test(part)) { | ||
| selected.push(toPageNumber(part)); | ||
| } else { | ||
| throw new Error(`Invalid page selection "${part}": expected a page number or a range like "2-5"`); | ||
| } | ||
| } | ||
|
|
||
| return selected; | ||
| } | ||
|
|
||
| function toPageNumber(value) { | ||
| let n = Number(value); | ||
| if (!Number.isInteger(n) || n < 1) { | ||
| throw new Error(`Invalid page number "${value}": page numbers are 1-based integers`); | ||
| } | ||
| return n; | ||
| } | ||
|
|
||
| function range(from, to) { | ||
| let out = []; | ||
| for (let i = from; i <= to; i++) out.push(i); | ||
| return out; | ||
| } | ||
|
|
||
| export function resolvePages({ pages, excludePages } = {}, pageCount) { | ||
| if (!Number.isInteger(pageCount) || pageCount < 1) { | ||
| throw new Error(`Invalid page count: ${pageCount}`); | ||
| } | ||
|
|
||
| let selected = parseSelection(pages, pageCount); | ||
| let excluded = new Set(excludePages == null ? [] : parseSelection(excludePages, pageCount)); | ||
|
|
||
| let outOfRange = [...new Set(selected.filter(p => p > pageCount))]; | ||
| if (outOfRange.length) { | ||
| throw new Error( | ||
| `Requested page${outOfRange.length > 1 ? 's' : ''} ${outOfRange.join(', ')} ` + | ||
| `but the document has only ${pageCount} page${pageCount > 1 ? 's' : ''}` | ||
| ); | ||
| } | ||
|
|
||
| let resolved = [...new Set(selected)] | ||
| .filter(p => !excluded.has(p)) | ||
| .sort((a, b) => a - b); | ||
|
|
||
| if (!resolved.length) { | ||
| throw new Error('No pages left to snapshot after applying `pages` and `excludePages`'); | ||
| } | ||
|
|
||
| if (resolved.length > MAX_PAGES) { | ||
| throw new Error( | ||
| `Requested ${resolved.length} pages but the maximum per request is ${MAX_PAGES}. ` + | ||
| 'Narrow the selection with `pages` (for example "1-100") and issue several requests.' | ||
| ); | ||
| } | ||
|
Comment on lines
+79
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: sed -n '1,90p' packages/cli-pdf/src/pages.jsRepository: percy/cli Length of output: 3148 Denial of Service Reachability: External Reject oversized ranges before expansion.
🤖 Prompt for AI Agents |
||
|
|
||
| return resolved; | ||
| } | ||
|
|
||
| export { parseSelection as _parseSelection }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| env: | ||
| jasmine: true | ||
| rules: | ||
| import/no-extraneous-dependencies: off | ||
| no-return-assign: off | ||
| no-sequences: off |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
| import { pdfjsAssets } from '../src/assets.js'; | ||
|
|
||
| describe('@percy/cli-pdf assets', () => { | ||
| let assets = pdfjsAssets(); | ||
|
|
||
| it('resolves the installed pdfjs-dist root', () => { | ||
| expect(fs.existsSync(path.join(assets.root, 'package.json'))).toBe(true); | ||
| }); | ||
|
|
||
| it('points at the legacy build directory', () => { | ||
| expect(fs.existsSync(assets.buildDir)).toBe(true); | ||
| expect(fs.existsSync(path.join(assets.buildDir, 'pdf.js'))).toBe(true); | ||
| expect(fs.existsSync(path.join(assets.buildDir, assets.workerFile))).toBe(true); | ||
| }); | ||
|
|
||
| it('points at the font and cmap data pdf.js fetches at runtime', () => { | ||
| expect(fs.existsSync(assets.standardFontsDir)).toBe(true); | ||
| expect(fs.existsSync(assets.cmapsDir)).toBe(true); | ||
| expect(fs.readdirSync(assets.standardFontsDir).length).toBeGreaterThan(0); | ||
| expect(fs.readdirSync(assets.cmapsDir).length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('exposes the injectable pdf.js library file', () => { | ||
| expect(fs.existsSync(assets.libPath)).toBe(true); | ||
| expect(fs.readFileSync(assets.libPath, 'utf-8')).toContain('getDocument'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift
Denial of Service
Reachability: External
Exploitability: Moderate
CWE: CWE-400 — Uncontrolled Resource Consumption
Bound total raster output, not only page count.
The rasterizer stores every rendered PNG buffer in
pagesbefore queueing snapshots. A 250-page PDF can therefore consume excessive memory. Add an aggregate raster-byte budget, or queue each page before rendering the next page.🤖 Prompt for AI Agents