feat(cli-pdf): native PDF visual testing via POST /percy/pdf/snapshot - #2418
Open
RaghavsBrowserStack wants to merge 9 commits into
Open
feat(cli-pdf): native PDF visual testing via POST /percy/pdf/snapshot#2418RaghavsBrowserStack wants to merge 9 commits into
RaghavsBrowserStack wants to merge 9 commits into
Conversation
Adds first-class PDF support to the CLI so an SDK can hand over PDF bytes and get one Percy snapshot per page, with synchronous comparison results. This is the replacement for the external percy-pdf solution, which wrapped the CLI from outside by serving a pdf.js viewer and driving Percy's renderer through the viewer's DOM with per-page `execute` scripts. New package @percy/cli-pdf is a leaf library: PDF bytes in, page rasters and their root DOM out. It holds no reference to @percy/core, which is what lets core list it as an optionalDependency without a cycle -- users who never snapshot a PDF do not install pdfjs-dist or the @napi-rs/canvas prebuilds. @percy/core gains the POST /percy/pdf/snapshot route and pdf-snapshot.js, which validates the request, decodes the base64 document, lazily imports @percy/cli-pdf, and pushes one snapshot per selected page through percy.upload() with `resources` as a function so rasterizing happens inside the queue task and inherits its concurrency. Each page carries resources and no `tag`, so createSnapshotsQueue routes it via client.sendSnapshot -- these are real web snapshots, not comparisons. This mirrors cli-upload's web-token path. The document travels as base64 in an ordinary JSON body and the sync response is always a JSON object (never a bare array) carrying a per-page array. Both are deliberate: every SDK, including the .NET wrapper's Dictionary-to-JSON helper and its JObject.Parse of the response, can call this with the HTTP client it already has, with no multipart or streaming code. Page snapshots are named `<name> | Page N`, matching percy-pdf exactly so teams migrating keep their approved baselines instead of orphaning them. Oversized pages are fitted rather than rejected: Legal (1224x2016 at scale 2) and A3 exceed Percy's 2000px cap and are exactly the documents this targets, so fitScale reduces the scale deterministically from the page's own dimensions and warns. pdfjs-dist is pinned to 4.x rather than 6.x, which requires Node >=22.13. @percy/sdk-utils exports postPdfSnapshot as the shared seam every SDK wraps. Note that sync mode is only reachable through this endpoint under `percy exec`: percy.syncMode() force-disables sync under skipUploads/deferUploads/delayUploads, which the `snapshot` and `upload` commands set. A `percy pdf <dir>` command could therefore never return comparison results, so none is added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI installs on Node 14.21.3 and yarn enforces `engines` across the whole tree,
so `yarn install` aborted with:
error @percy/cli-pdf@1.32.8: The engine "node" is incompatible with this
module. Expected version ">=18". Got "14.21.3"
Dropping cli-pdf's own `engines` field is not sufficient: pdfjs-dist declares
`node: ">=18"` from 3.x onward, so yarn would fail on the dependency instead.
pdfjs-dist 2.16.105 is the last line that declares no engines constraint, and
@napi-rs/canvas is already `>= 10`, so 2.x is what keeps the repo installable on
its current Node floor.
Rasterization output is equivalent -- verified end to end: unchanged document
gives zero diffs on every page, and a document changed on page 2 only reports a
diff on page 2 while pages 1 and 3 stay at zero.
The 2.x legacy build is CommonJS rather than ESM, so the import moves to
pdfjs-dist/legacy/build/pdf.js with `mod.default ?? mod` interop. cli-pdf's
engines now matches its sibling packages at >=14.
Also removes source comments across the PDF changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Syncs the PDF snapshot work with the 1.32.9 release. Conflict was in packages/core/package.json optionalDependencies: master bumped @percy/cli-doctor to 1.32.9 while this branch added @percy/cli-pdf at 1.32.8. Resolved by keeping both at 1.32.9. @percy/cli-pdf's own version and its @percy/logger dependency were still pinned at 1.32.8, so both are bumped to 1.32.9 -- every package version and @percy/* dependency in the workspace now matches lerna.json again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
packages/cli-command/test/noRequireBinding.test.js guards every packages/*/src file against `const require = createRequire(...)`: the name collides with Babel's transforms and crashes the packaged pkg binary with "_require is not a function". rasterize.js needed it to resolve pdfjs-dist's on-disk standard_fonts and cmaps directories, so the binding is renamed to cjsRequire as the guard suggests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Test workflow's package list is hardcoded, so @percy/cli-pdf's suite was never running in CI at all -- the same gap #2402 closed for cli-app. Added to the matrix. CI runs test:coverage, which enforces the repo's 100% threshold, so two dead spots had to go first: - loadDocument used `mod.default ?? mod` for CJS interop, but the pdfjs legacy build always exposes `.default` (verified: `mod.default` is an object while `mod.getDocument` is undefined), leaving `?? mod` unreachable. It now reads `mod.default` directly. - pages.js skips empty segments in a string selection and nothing exercised that path. Added coverage for '1,,3' and '2,', plus the case where a selection resolves to no pages at all. Verified the rasterizer really does work on Node 14, the matrix version: pdfjs 2.16.105 plus the @napi-rs/canvas native binding render all three fixture pages with identical non-white pixel counts to Node 22. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…canvas The discovery Chromium is already launched eagerly by percy.start() and sits completely idle for the whole of a PDF run (verified: every discovery queueInfo line reports total 0). Shipping @napi-rs/canvas alongside it meant paying 25MB of platform-specific native prebuilds, plus a native-binary dependency in a widely distributed CLI, to duplicate a renderer already present and running. pdf.js now runs inside a browser page instead of in the Node process: - @percy/cli-pdf drops @napi-rs/canvas entirely and becomes pure helpers plus pdf.js assets: page selection, the page DOM, pdfjs-dist asset paths, and the functions that execute in the page context. It remains an optionalDependency so nobody pays for pdfjs-dist's 34MB unless they snapshot a PDF. - @percy/core gains pdf-rasterize.js, which owns the browser work: a throwaway loopback origin (Server.serve) exposing pdf.js, its worker, standard_fonts, cmaps and the document itself, then a page that injects pdf.js and renders each selected page to a canvas, returning a PNG data URL per page. Serving the assets over a real origin is what makes standard fonts work: pdf.js fetches standardFontDataUrl/cMapUrl over HTTP, and base-14 fonts such as Helvetica are not embedded in most documents. isEvalSupported stays false -- PDFs are untrusted input. The rasterizer now calls percy.browser.launch() explicitly. It is idempotent, and this makes PDF snapshots work under skipDiscovery, where the eager launch does not happen. Two side effects worth noting. Rendering is now the CLI's pinned Chromium rather than a separately versioned Skia, so page rasters are as reproducible as the rest of Percy's pipeline instead of tracking a native dependency's version. And existing PDF baselines must be regenerated, since the rasterizer changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI reported browser-scripts.js at 23.81% statements: openDocument, measurePages, renderPage and destroyDocument are serialized and executed in the browser page, so nothing in the Node suite ever ran them. pages.js:59 also had one uncovered branch, the singular form of the out-of-range message. Rather than mark the page scripts ignored, the suite now stands up fake `window` and `document` globals and invokes them directly. That covers the code and asserts behaviour that was genuinely untested: - the exact URLs pdf.js is handed (worker, document, standard_fonts, cmaps) and that isEvalSupported stays false - the window.pdfjsLib fallback, and the error when pdf.js never initialised - fractional viewports rounding up - the white canvas pre-fill, without which transparent PDF regions rasterize to alpha-0 black and diff against anything - page handles being released even when rendering rejects Adds a 1-page-document case so both arms of the pluralisation in the out-of-range message are exercised. 44 specs, all passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All 1278 core specs passed, but the job failed on coverage:
pdf-rasterize.js | 95.24 | 70.00 | 100 | 95.24 | 36,64
pdf-snapshot.js | 95.89 | 95.12 | 100 | 95.71 | 34,52,118
Every gap was an error or warning path. Now covered: the invalid-scale guard
(each of its three arms), the fitScale warning via a Legal-size page, the
too-short-base64 and non-object-pdf branches of decodePdf, a blank name, a
non-object request body, an empty body, a browser failure surfacing as a
rasterization error, and a page returning a failure status.
Two small production changes fell out of writing them:
- loadPdfModule takes an injectable loader, defaulting to the real dynamic
import, so the 501 "package is not installed" path is reachable from a test
instead of only when the optional dependency is genuinely absent.
- The body guard now also rejects Buffers. api.js leaves req.body as raw bytes
when JSON.parse fails, and `typeof Buffer === 'object'`, so a malformed body
slipped past the check and produced a confusing "Missing required `name`"
rather than "Expected a JSON object body". Found by the test.
rasterizePdf's `options = {}` default is dropped: its single caller always
passes options, so the default arm was unreachable branch weight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Central YAML (base), Workspace UI (inherited) Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…ion path
Two problems, found by comparing against example-non-rendering-project.
1. buildPageHtml duplicated cli-upload's wrapper DOM. That shape is a contract
with percy-api, not cosmetics: extract_and_process_upload_snapshot recovers
the image by matching
/<img\s+src="([^"]+)"\s+width="(\d+)px"\s+height="(\d+)px"/
against the root resource. A mismatch is not an error -- extraction raises,
percy-api rescues, and the snapshot silently falls back to being rendered. Two
divergent copies of that was a latent bug, and mine had already drifted
(a trailing alt="" plus extra CSS; harmless only because the regex is
unanchored).
The wrapper now lives once, in core's utils as buildImageSnapshotHtml /
createImageSnapshotResources. cli-upload's getImageResources delegates to it,
and the PDF path uses it, so buildPageHtml is gone. core cannot import
cli-upload (cli-upload -> cli-command -> core would cycle), but cli-upload
already reaches core's utils via @percy/cli-command/utils, so this needs no
new dependency either way.
image-snapshot-resources.test.js pins percy-api's regex verbatim, so drift is
caught in CI rather than degrading silently in production.
2. PDF pages were not taking the extraction path at all. Comparison#upload_snapshot?
gates on `user_agent&.include?('@percy/cli-upload')` plus a root resource URL
under http://local/. The PDF endpoint only ever forwarded the SDK's own
clientInfo, so every page was fully re-rendered by the renderer fleet despite
the CLI already having produced the exact PNG. Measured per page, same
document: 19s/11s/9s rendered versus 1s/1s/1s extracted.
The endpoint now also tags the build with @percy/cli-pdf and @percy/cli-upload.
The percy-api check is a substring match, so naming cli-pdf alongside keeps the
User-Agent honest about which code ran instead of impersonating the upload
command.
Note for operators: upload_extraction_allowed? only short-circuits on a project's
default base branch. Elsewhere it mirrors the base comparison, so existing
baselines need regenerating before comparison builds will extract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds first-class PDF visual testing to the CLI. An SDK hands over PDF bytes; the CLI rasterizes the document and creates one Percy snapshot per page, optionally blocking on synchronous comparison results.
This is the in-CLI replacement for the external
percy-pdfsolution, which can be retired once this ships.Why not the old approach
percy-pdfwrapped the CLI from the outside: it unzipped a 5.8 MB pdf.js viewer, served it overhttp-server, generated apercy snapshotYAML config per document, and captured pages by having Percy's renderer run the viewer and deleting already-captureddiv.pagechildren from the DOM between snapshots soscope: .canvasWrapperwould land on the next page.That design carried three bugs that are unrepresentable in this one:
executescript was assigned inside aforEachover every previously-pushed snapshot, so all snapshots ended up with the last page's script. Any gap fromexcludePagesmeant every later page captured the wrong page under the right name.excludePages: [1]now just works.How it's implemented
Rendering happens in the discovery browser
The CLI already launches Chromium eagerly —
percy.start()runsif (!this.skipDiscovery) yield this.#discovery.start(), whosestarthandler callspercy.browser.launch(). During a PDF-only run that browser sits completely idle: everydiscovery queueInfoline reports{"queued":0,"pending":0,"total":0}, becausepercy.upload()bypassesdiscoverSnapshotResources.So pdf.js runs in a browser page rather than in Node. An earlier revision of this PR rasterized with
@napi-rs/canvas(Skia); that shipped ~25MB of platform-specific native prebuilds to duplicate a renderer already present and running, and has been removed. A second benefit: rendering is now the CLI's pinned Chromium revision, so page rasters are as reproducible as the rest of Percy's pipeline instead of tracking a separately versioned native dependency.@percy/cli-pdf(new package) — helpers and pdf.js assetsNo reference to
@percy/core, which is what lets core list it as anoptionalDependencywithout a cycle. Its only heavyweight cost is pdfjs-dist's ~34MB of library, fonts and cmaps, which nobody installs unless they snapshot a PDF.pages.jsresolvePages()— parses3,[1,2,5],"1-5","1,3,8","2-"; appliesexcludePages; validates against the real page countbrowser-scripts.jsopenDocument,measurePages,renderPage,destroyDocument) plus the scale/dimension limitsassets.jspdfjsAssets()— the installed pdfjs-dist paths the asset server and page injection needThe wrapper DOM is not in this package — it is shared with
@percy/cli-upload, see below.@percy/core/src/pdf-rasterize.js— the browser workrasterizePdf(percy, buffer, options)stands up a throwaway loopback origin (Server.serve) exposing pdf.js, its worker,standard_fonts,cmapsand the document itself; opens an isolated page on it; injects pdf.js; then measures and renders each selected page to a PNG data URL.Serving the assets over a real origin is what makes standard fonts work. pdf.js fetches
standardFontDataUrl/cMapUrlover HTTP at render time, and the base-14 fonts (Helvetica, Times…) aren't embedded in most documents — without a reachable origin pdf.js renders wrong metrics or drops glyphs and only warns.isEvalSupported: falseis still passed, because the PDF itself is untrusted input.It calls
percy.browser.launch()explicitly. That's idempotent (if (this.readyState != null) return), and it makes PDF snapshots work underskipDiscovery, where the eager launch doesn't happen.@percy/core— the endpointPOST /percy/pdf/snapshot→src/pdf-snapshot.js, which validates the request, decodes the base64 document (50 MB cap and%PDF-magic-byte check, matching/percy/comparison/upload), lazily imports@percy/cli-pdf, and fans out.Each page carries
resourcesand notag, socreateSnapshotsQueue's task handler picksclient.sendSnapshotoversendComparison: these are real web snapshots, not comparisons.resourcesis passed as a function (the lazy-resource patterncli-uploaduses) so the root-DOM and resource-object construction is deferred into the queue task.Concurrency, precisely
page.evalper page against a single browser page.percy.upload()in a single synchronous pass.concurrency(discovery.concurrency, default 10).WaitForJobcollects every pending job id and issues a singlegetStatus(job_status?...&id=a,b,c) per interval, so a 10-page PDF costs one request per poll, not ten.Promise.alloverhandleSyncJob.Only rasterization is serial; nothing blocks page-by-page.
Sync mode attaches
{ resolve, reject }per page (mirroring the/percy/comparisonroute), then aggregates each page'shandleSyncJobresult.handleSyncJobconverts failures into{ error }rather than rejecting, so one bad page yields a partial result instead of losing every other page's.@percy/sdk-utils— the shared seampostPdfSnapshot(), exported alongsidepostSnapshot, so all SDKs can re-export it.Pages are extracted, not rendered
percy-api can skip the renderer entirely for image-backed snapshots.
Comparison#upload_snapshot?(app/models/percy/comparison.rb) gates on exactly two things:When both hold,
start_comparison_job.rbcallsextract_and_process_upload_snapshot, which recovers the image by matching the root resource against/<img\s+src="([^"]+)"\s+width="(\d+)px"\s+height="(\d+)px"/and returns beforeRenderJobis enqueued.An earlier revision of this PR was not taking that path, so every PDF page was fully re-rendered by the renderer fleet despite the CLI already having produced the exact PNG. Measured on the same 3-page document:
The endpoint now tags the build's User-Agent with
@percy/cli-pdf/<version>and@percy/cli-upload/<version>. The gate is a substring match, so naming both unlocks extraction while keeping the UA honest about which code actually ran, rather than impersonating the upload command.One wrapper, shared with cli-upload
That wrapper HTML is a contract with percy-api, not cosmetics — and a mismatch is not an error: extraction raises, percy-api rescues it, and the snapshot silently falls back to being rendered at ~10x the processing cost, with nothing surfaced to the user.
It therefore has exactly one definition,
buildImageSnapshotHtml/createImageSnapshotResourcesin@percy/core'sutils.js.cli-upload'sgetImageResourcesdelegates to it, and the PDF path uses it. core cannot importcli-upload(cli-upload->cli-command->corewould cycle), butcli-uploadalready reaches core's utils through@percy/cli-command/utils, so this needs no new dependency.cli-upload's existing suite passes unchanged, confirming byte-identical output.core/test/image-snapshot-resources.test.jspins percy-api's regex verbatim so drift fails CI instead of degrading silently in production.Operator note:
upload_extraction_allowed?only short-circuits on a project's default base branch. Elsewhere it mirrors the base comparison'supload_snapshot_extractedflag, so baselines taken before this change must be regenerated before comparison builds will extract.Two contract decisions worth calling out
The document travels as base64 in an ordinary JSON body, and the sync response is always a JSON object, never a bare array. Both exist so every SDK can call this with the HTTP client it already has. Concretely, for the .NET wrapper: the request goes through its existing
Dictionary<string, object>→JsonSerializerhelper, and the response through its existingJObject.Parse— no multipart, no streaming, no new HTTP machinery. The cost is base64's 33% inflation, which is the right trade for a one-line integration across ~26 SDKs.Sync response shape
{ "success": true, "data": { "pdf-name": "Burglary Insurance Policy", "page-count": 3, "pages-snapshotted": 3, "status": "success", "pages": [ { "page": 1, "snapshot-name": "Burglary Insurance Policy | Page 1", "screenshots": [ { "diff-info": { "diff-ratio": 0 } } ] }, { "page": 2, "snapshot-name": "Burglary Insurance Policy | Page 2", "screenshots": [ { "diff-info": { "diff-ratio": 0.00085774 } } ] } ] } }pageandsnapshot-nameare set after the API payload is spread in, so the values we submitted always win over whatever the API echoes back — otherwise a change in the API's naming would silently break the caller's page mapping.Notes for reviewers
percy-pdfexactly (<name> | Page N) so teams migrating keep their approved baselines instead of orphaning every one.percy pdf <dir>command, deliberately.percy.syncMode()force-disables sync underskipUploads/deferUploads/delayUploads, which is exactly what thesnapshotanduploadcommands set. A command could therefore never return comparison results, so PDF support is reachable only through this endpoint underpercy exec.fitScale()reduces the scale deterministically from the page's own dimensions and warns. Deterministic from page geometry means the same document always rasterizes identically, which is what a stable baseline needs.pdfjs-distpinned to 4.x, not 6.x — 6.x requires Node >= 22.13, while 4.8.69 needs only Node >= 18, matching the CLI's engine range.standardFontDataUrlandcMapUrlare configured explicitly. Without them pdf.js renders standard fonts with wrong metrics or drops CJK glyphs and only warns, which would surface as a mysterious visual diff rather than an error.isEvalSupported: false— PDFs are untrusted input arriving over the local API, and this is the one pdf.js switch that permits code execution.standardFontDataUrl/cMapUrlare served, not configured away. Without them pdf.js renders standard fonts with wrong metrics or drops CJK glyphs, and only warns — which would surface as a mysterious visual diff rather than an error.new Function(pdfjsSource)mirrorspage.insertPercyDom()(page.js:239); the input is pdfjs-dist's own vendored file, never user data.A bug caught during development
The first end-to-end run reported
max diff-ratio=0for a document that genuinely differed — both the passing and failing functional tests passed vacuously.The wrapper (then a PDF-local
buildPageHtml, since replaced by the shared one) ranencodeURIover an already percent-encoded image URL, turning%20into%2520. The<img src>then matched no registered resource, so every page rendered as the same blank sheet and all pages collapsed to a single image hash. It surfaced only by noticing that page 2 and page 3 shared acurrent-imagehash in the raw sync payload.Fixed to HTML-escape only. The guards now live in
packages/core/test/image-snapshot-resources.test.js("does not re-encode an already-encoded URL", "keeps the img src and the image resource URL identical") andpackages/core/test/pdf-snapshot.test.js("attaches a root DOM whose img src matches the image resource", "gives each page a distinct image resource"). This is a failure mode that reports green, so it is worth guarding directly.Node compatibility
pdfjs-distis pinned to 2.16.105, the last line that declares noenginesconstraint. 3.x and later declarenode: ">=18", and since yarn enforcesenginesacross the whole tree, anything newer breaksyarn installon the repo's Node 14 CI — not just for PDF users, but for everyone.@percy/cli-pdftherefore declares>=14, matching its sibling packages.Testing
@percy/cli-pdf@percy/core(test/pdf-snapshot.test.js, 19 specs)@percy/sdk-utils(postPdfSnapshot, 5 specs)eslinton all changed filesVerified end to end against a real Percy project using a Selenium + Mocha harness that drives a browser, downloads a PDF via a download button, and hands the bytes to this endpoint:
diff-ratio 0, exit 0page 2 … diff-ratio 0.00085774, while pages 1 and 3 report 0That last line is the point of per-page snapshots: a one-page change in a multi-page document is localised rather than flagging the whole file.
Follow-ups
postPdfSnapshot. APercy.PdfSnapshot(string name, byte[] pdf, Dictionary<string, object>? options)implementation forpercy-selenium-dotnetis written against this contract and reuses its existingRequest()helper unchanged; it will be raised separately.percy-pdfpointing here.🤖 Generated with Claude Code