diff --git a/packages/core/test/pdf-snapshot.test.js b/packages/core/test/pdf-snapshot.test.js index 503c0c40c..bcba8fdca 100644 --- a/packages/core/test/pdf-snapshot.test.js +++ b/packages/core/test/pdf-snapshot.test.js @@ -287,6 +287,21 @@ describe('PDF snapshots', () => { new RegExp(`Requested ${pageCount} pages but the maximum per request is ${MAX_PAGES}`)); }); + it('rejects a page that rasterizes below Percy\'s minimum', async () => { + // fitScale only clamps the upper bound, so a tiny MediaBox still renders + // under the 10px floor -- and the caller can fix it by raising `scale`, + // which is what makes it a 400 rather than a 500. + let [body, res] = await postRaw({ + name: 'doc', + pdf: { content: b64(buildPdf({ width: 4, height: 4 })) }, + scale: 2 + }); + + expect(res.statusCode).toBe(400); + expect(body.error).toMatch( + /Page 1 rasterized to 8x8px, below Percy's 10px minimum\. Increase `scale`\./); + }); + it('warns but proceeds on an unrecognised option', async () => { await post({ name: 'doc', @@ -325,6 +340,20 @@ describe('PDF snapshots', () => { expect(() => decodePdf({ content: b64(big) })).toThrowMatching( e => e.status === 413 && /maximum size of 50MB/.test(e.message)); }); + + it('rejects a PDF that only goes over once decoded', () => { + // The encoded cap rounds up to a whole base64 quantum, so a payload can + // clear it and still decode past 50MB -- by at most two bytes, which is + // exactly the window the second check exists to close. 52428801 bytes is + // the largest such buffer: divisible by 3, so it encodes to precisely + // MAX_PDF_BASE64_CHARS with no padding. + let big = Buffer.alloc(50 * 1024 * 1024 + 1); + let content = b64(big); + + expect(content.length).toBe(Math.ceil((50 * 1024 * 1024) / 3) * 4); + expect(() => decodePdf({ content })).toThrowMatching( + e => e.status === 413 && /maximum size of 50MB/.test(e.message)); + }); }); describe('loadPdfModule', () => { diff --git a/test/regression/pdf-render.test.js b/test/regression/pdf-render.test.js index 119af4df2..e937764f2 100644 --- a/test/regression/pdf-render.test.js +++ b/test/regression/pdf-render.test.js @@ -17,10 +17,10 @@ // Goldens therefore live under `expected/-/`, each set with // its own `manifest.json` recording the browser build it came from. CI // (linux-x64) and a macOS dev machine each compare against their own set. -// 2. Pages containing raster images are not byte-reproducible even on one -// machine: Chromium picks between two anti-aliasing paths for a clipped -// image edge from run to run. Those pages declare a measured pixel budget -// and fail if the difference exceeds it. +// 2. Some pages are not byte-reproducible even on one machine: Chromium picks +// between anti-aliasing paths from run to run, both for a clipped image +// edge and for a thin rule landing between sub-pixels. Those pages declare +// a measured pixel budget and fail if the difference exceeds it. // // Run: yarn test:regression:pdf // Regenerate: yarn test:regression:pdf --update @@ -43,17 +43,26 @@ const UPDATE = process.argv.includes('--update') || process.env.UPDATE_PDF_GOLDE // not listed here must match byte-for-byte; these budgets are the only escape // hatch, and they are deliberately tight. // -// `jack sparrow resume.pdf` embeds a photo clipped to a circle. Chromium -// rasterizes that clip edge one of two ways depending on how the decoded image -// lands, so ~6 runs in 15 differ from the golden — always the same 270 pixels -// in the same 193x193 box around the photo, never more than 52 per channel, out -// of 2,005,644 pixels (0.013%). The budget is ~2x the measured worst case: a -// real rendering regression moves glyphs or layout and blows straight past it. +// `jack sparrow resume.pdf` has two independent, measured sources of run-to-run +// jitter on a fixed Chromium build, out of 2,005,644 pixels: +// +// - The photo clipped to a circle. Chromium rasterizes that clip edge one of +// two ways depending on how the decoded image lands, so ~6 runs in 15 differ +// from the golden — always the same 270 pixels in the same 193x193 box +// around the photo, never more than 52 per channel. +// - The section rule under "LANGUAGES", a hairline that straddles two pixel +// rows and blends into them differently between runs: the full 324x2 box +// [369, 1447, 692, 1448], 648 pixels at a channel delta of 4 — +// imperceptible, but enough to move the PNG bytes. +// +// Worst case is both at once (918 pixels); the budget is ~2x that, still 0.1% of +// the page. A real rendering regression moves glyphs or layout and blows +// straight past it — and a size change is never tolerated at all. const TOLERANCES = { 'jack-sparrow-resume': { - maxDiffPixels: 600, + maxDiffPixels: 2000, maxChannelDelta: 96, - reason: 'Chromium anti-aliases the circular photo clip differently between runs' + reason: 'Chromium anti-aliases the circular photo clip and a horizontal rule differently between runs' } };