Skip to content

feat(cli-pdf): native PDF visual testing via POST /percy/pdf/snapshot - #2418

Open
RaghavsBrowserStack wants to merge 9 commits into
masterfrom
PPLT-6073
Open

feat(cli-pdf): native PDF visual testing via POST /percy/pdf/snapshot#2418
RaghavsBrowserStack wants to merge 9 commits into
masterfrom
PPLT-6073

Conversation

@RaghavsBrowserStack

@RaghavsBrowserStack RaghavsBrowserStack commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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-pdf solution, which can be retired once this ships.

const { postPdfSnapshot } = require('@percy/sdk-utils');

const result = await postPdfSnapshot({
  name: 'Burglary Insurance Policy',
  sync: true,
  pdf: { content: fs.readFileSync('policy.pdf').toString('base64') },
  excludePages: [4, 7]
});
// result.body.data.pages[] -> one entry per page, each with its own diff-info

Why not the old approach

percy-pdf wrapped the CLI from the outside: it unzipped a 5.8 MB pdf.js viewer, served it over http-server, generated a percy snapshot YAML config per document, and captured pages by having Percy's renderer run the viewer and deleting already-captured div.page children from the DOM between snapshots so scope: .canvasWrapper would land on the next page.

That design carried three bugs that are unrepresentable in this one:

  1. Page filtering mislabelled snapshots. The per-page execute script was assigned inside a forEach over every previously-pushed snapshot, so all snapshots ended up with the last page's script. Any gap from excludePages meant every later page captured the wrong page under the right name.
  2. Page 1 and the second-to-last page could not be excluded — page 1 was structurally the base snapshot, and the second-to-last was entangled with the viewer's page-prefetch step. excludePages: [1] now just works.
  3. Page counts were read from the baseline directory even when comparing a release, so a release with extra pages silently lost them.

How it's implemented

Rendering happens in the discovery browser

The CLI already launches Chromium eagerly — percy.start() runs if (!this.skipDiscovery) yield this.#discovery.start(), whose start handler calls percy.browser.launch(). During a PDF-only run that browser sits completely idle: every discovery queueInfo line reports {"queued":0,"pending":0,"total":0}, because percy.upload() bypasses discoverSnapshotResources.

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 assets

No reference to @percy/core, which is what lets core list it as an optionalDependency without a cycle. Its only heavyweight cost is pdfjs-dist's ~34MB of library, fonts and cmaps, which nobody installs unless they snapshot a PDF.

Module Responsibility
pages.js resolvePages() — parses 3, [1,2,5], "1-5", "1,3,8", "2-"; applies excludePages; validates against the real page count
browser-scripts.js The functions that execute inside the page (openDocument, measurePages, renderPage, destroyDocument) plus the scale/dimension limits
assets.js pdfjsAssets() — the installed pdfjs-dist paths the asset server and page injection need

The wrapper DOM is not in this package — it is shared with @percy/cli-upload, see below.

@percy/core/src/pdf-rasterize.js — the browser work

rasterizePdf(percy, buffer, options) stands up a throwaway loopback origin (Server.serve) exposing pdf.js, its worker, standard_fonts, cmaps and 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 / cMapUrl over 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: false is 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 under skipDiscovery, where the eager launch doesn't happen.

@percy/core — the endpoint

POST /percy/pdf/snapshotsrc/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 resources and no tag, so createSnapshotsQueue's task handler picks client.sendSnapshot over sendComparison: these are real web snapshots, not comparisons. resources is passed as a function (the lazy-resource pattern cli-upload uses) so the root-DOM and resource-object construction is deferred into the queue task.

Concurrency, precisely

Stage Behaviour
Rendering pages → PNG Sequential, one page.eval per page against a single browser page.
Queuing the N snapshots All at once, non-blocking. All pages are pushed via percy.upload() in a single synchronous pass.
Uploading Concurrent, up to the snapshots queue's concurrency (discovery.concurrency, default 10).
Polling for completion One shared batched poll. WaitForJob collects every pending job id and issues a single getStatus (job_status?...&id=a,b,c) per interval, so a 10-page PDF costs one request per poll, not ten.
Fetching final details ParallelPromise.all over handleSyncJob.

Only rasterization is serial; nothing blocks page-by-page.

Sync mode attaches { resolve, reject } per page (mirroring the /percy/comparison route), then aggregates each page's handleSyncJob result. handleSyncJob converts 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 seam

postPdfSnapshot(), exported alongside postSnapshot, 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:

unless user_agent&.include?('@percy/cli-upload')   # substring match
unless resource_url&.include?('http://local/')     # the ROOT resource URL

When both hold, start_comparison_job.rb calls extract_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 before RenderJob is 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:

Per-page processing
Rendered (no marker) 19s, 11s, 9s
Extracted 1s, 1s, 1s

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 / createImageSnapshotResources in @percy/core's utils.js. cli-upload's getImageResources delegates to it, and the PDF path uses it. core cannot import cli-upload (cli-upload -> cli-command -> core would cycle), but cli-upload already 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.js pins 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's upload_snapshot_extracted flag, 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>JsonSerializer helper, and the response through its existing JObject.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 } } ] }
    ]
  }
}

page and snapshot-name are 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

  • Snapshot names match percy-pdf exactly (<name> | Page N) so teams migrating keep their approved baselines instead of orphaning every one.
  • No percy pdf <dir> command, deliberately. percy.syncMode() force-disables sync under skipUploads/deferUploads/delayUploads, which is exactly what the snapshot and upload commands set. A command could therefore never return comparison results, so PDF support is reachable only through this endpoint under percy exec.
  • Oversized pages are fitted, not rejected. Legal (1224×2016 at scale 2) and A3 exceed Percy's 2000 px cap and are precisely the documents this targets, so 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-dist pinned 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.
  • standardFontDataUrl and cMapUrl are 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 / cMapUrl are 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) mirrors page.insertPercyDom() (page.js:239); the input is pdfjs-dist's own vendored file, never user data.
  • Baselines from the earlier revision of this PR must be regenerated, since the rasterizer changed from Skia to Chromium.

A bug caught during development

The first end-to-end run reported max diff-ratio=0 for 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) ran encodeURI over an already percent-encoded image URL, turning %20 into %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 a current-image hash 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") and packages/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-dist is pinned to 2.16.105, the last line that declares no engines constraint. 3.x and later declare node: ">=18", and since yarn enforces engines across the whole tree, anything newer breaks yarn install on the repo's Node 14 CI — not just for PDF users, but for everyone. @percy/cli-pdf therefore declares >=14, matching its sibling packages.

Testing

Suite Result
@percy/cli-pdf 32/32 pass
@percy/core (test/pdf-snapshot.test.js, 19 specs) 19/19 pass
@percy/sdk-utils (postPdfSnapshot, 5 specs) 5/5 pass
eslint on all changed files clean

Verified 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:

  • unchanged document → all 3 pages diff-ratio 0, exit 0
  • document changed on page 2 only → exit 1, page 2 … diff-ratio 0.00085774, while pages 1 and 3 report 0

That 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

  • SDK wrappers exposing postPdfSnapshot. A Percy.PdfSnapshot(string name, byte[] pdf, Dictionary<string, object>? options) implementation for percy-selenium-dotnet is written against this contract and reuses its existing Request() helper unchanged; it will be raised separately.
  • Customer-facing docs, and a deprecation notice on percy-pdf pointing here.

🤖 Generated with Claude Code

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>
RaghavsBrowserStack and others added 6 commits September 8, 2026 16:19
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>
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Workspace UI (inherited)

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 9b191b40-9138-4295-8f7d-168fa307c7b6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant